From 913d98b7432b6fb9a55db6777d8e542e577353df Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:17:25 +0500 Subject: [PATCH 1/7] Say Sozo where the code still said Soplay The navigation bar's private classes and the subject of a shared player log were the last places the old name was left. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/core/diagnostics/log_viewer_sheet.dart | 2 +- .../main/presentation/pages/main_page.dart | 28 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/core/diagnostics/log_viewer_sheet.dart b/lib/core/diagnostics/log_viewer_sheet.dart index a6cbc1b9..0603f76b 100644 --- a/lib/core/diagnostics/log_viewer_sheet.dart +++ b/lib/core/diagnostics/log_viewer_sheet.dart @@ -66,7 +66,7 @@ class _LogViewerSheetState extends State { } Future _share() async { - await Share.share(_log.formatForShare(), subject: 'Soplay player logs'); + await Share.share(_log.formatForShare(), subject: 'Sozo player logs'); } Color _colorFor(LogLevel level) => switch (level) { diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index fc931d67..10d43e88 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -408,7 +408,7 @@ class _MainPageState extends State with WidgetsBindingObserver { alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.only(bottom: 18), - child: _SoplayFloatingNav( + child: _SozoFloatingNav( index: _index, onTap: _onTabTap, items: defs, @@ -550,7 +550,7 @@ class _MainPageState extends State with WidgetsBindingObserver { // Classic: the original full-width frosted bar (keeps // the per-tab Showcase + real double-tap-to-refresh). if (style == NavPrefs.classic) { - return _SoplayClassicBar( + return _SozoClassicBar( index: _index, items: defs, shortsShowcaseKey: @@ -601,12 +601,12 @@ class _MainPageState extends State with WidgetsBindingObserver { // `classic` is handled above and is an // explicit user choice on every platform. child: nativeIosBar - ? _SoplayNativeGlassBar( + ? _SozoNativeGlassBar( index: _index, items: defs, onTabSelected: _handleTabTap, ) - : _SoplayGlassCapsule( + : _SozoGlassCapsule( index: _index, items: defs, glass: style == NavPrefs.glass, @@ -635,15 +635,15 @@ class _MainPageState extends State with WidgetsBindingObserver { /// Built on liquid_glass_widgets' [GlassTabBar.bottom]. Mounted only from the /// mobile Scaffold branch as a bottom-center overlay with `extendBody: true`, so /// the content scrolling behind it is refracted/blurred (native iOS-26 look). -/// Desktop keeps [_SoplayFloatingNav]; this is never mounted on desktop, and the +/// Desktop keeps [_SozoFloatingNav]; this is never mounted on desktop, and the /// glass shaders are pre-warmed / wrapped mobile-only in main.dart. /// /// The packaged bar exposes no per-tab GlobalKey or double-tap, so: the Shorts /// refresh Showcase highlights the whole capsule (Shorts is the central tab), /// and double-tap-to-refresh becomes re-tapping the already-active Shorts tab /// (see [_MainPageState._handleTabTap]). -class _SoplayGlassCapsule extends StatelessWidget { - const _SoplayGlassCapsule({ +class _SozoGlassCapsule extends StatelessWidget { + const _SozoGlassCapsule({ required this.index, required this.items, required this.glass, @@ -932,8 +932,8 @@ class _SoplayGlassCapsule extends StatelessWidget { /// UIKit wants SF Symbols, not Material glyphs, so [_sfSymbol] maps the app's /// tabs by [TabId] — the ids are stable, the [IconData] are not. A tab with no /// mapping falls back to a neutral symbol rather than crashing the bar. -class _SoplayNativeGlassBar extends StatelessWidget { - const _SoplayNativeGlassBar({ +class _SozoNativeGlassBar extends StatelessWidget { + const _SozoNativeGlassBar({ required this.index, required this.items, required this.onTabSelected, @@ -1146,8 +1146,8 @@ class _CapsuleTabSlot extends StatelessWidget { /// style option (Profile → Appearance). Unlike the glass capsule it keeps the /// per-tab Showcase anchor and double-tap-to-refresh, since each tab is its own /// [_ClassicNavButton]. -class _SoplayClassicBar extends StatelessWidget { - const _SoplayClassicBar({ +class _SozoClassicBar extends StatelessWidget { + const _SozoClassicBar({ required this.index, required this.items, required this.shortsShowcaseKey, @@ -1342,9 +1342,9 @@ class _ClassicNavButtonState extends State<_ClassicNavButton> { } /// Sozo-Desktop style floating bottom-center rounded pill navigation. -/// Desktop only — mobile uses [_SoplayGlassCapsule]. Reuses the same 5 tabs. -class _SoplayFloatingNav extends StatelessWidget { - const _SoplayFloatingNav({ +/// Desktop only — mobile uses [_SozoGlassCapsule]. Reuses the same 5 tabs. +class _SozoFloatingNav extends StatelessWidget { + const _SozoFloatingNav({ required this.index, required this.onTap, required this.items, From a961d3e7b7a0b8fd38bda4d2ecd9cbb0b132e655 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:17:38 +0500 Subject: [PATCH 2/7] Help & Support: write to the team from the app, and read their answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile → Help & Support lists a viewer's requests and opens a new one: a category, a message, and — for a guest — an optional contact. The player's error screen opens it already on Video playback, with the title, episode and source filled in. Diagnostics are attached only with the viewer's say-so (a switch, on by default) and shown in full before sending: app version, system, locale, time zone, player engine, network type, and the newest 400 lines of the player log, which is redacted as it is written; the server redacts it again. No account data, no email, no device identifiers. A guest's requests belong to a random key the install keeps (sent only to the support endpoints), so they can read the answer too, and still see them after signing in. An answer from support arrives as a push that opens the conversation. Translated into all 14 languages; the translation files also lose a duplicate profile.section_content key. Co-Authored-By: Claude Opus 5.5 (1M context) --- assets/translations/ar.json | 44 ++- assets/translations/ckb.json | 43 ++ assets/translations/de.json | 46 ++- assets/translations/en.json | 46 ++- assets/translations/es.json | 46 ++- assets/translations/fr.json | 46 ++- assets/translations/id.json | 46 ++- assets/translations/ku.json | 43 ++ assets/translations/nl.json | 46 ++- assets/translations/pt.json | 46 ++- assets/translations/ru.json | 46 ++- assets/translations/tr.json | 46 ++- assets/translations/uz.json | 46 ++- assets/translations/yue.json | 44 ++- lib/core/di/injection.dart | 7 + lib/core/diagnostics/player_log.dart | 25 +- lib/core/router/app_router.dart | 19 + lib/core/storage/hive_service.dart | 20 + .../pages/player_page.controls.dart | 25 ++ .../presentation/pages/player_page.dart | 1 + .../presentation/notification_routing.dart | 3 + .../presentation/pages/profile_page.hub.dart | 7 + .../support/data/support_diagnostics.dart | 83 ++++ lib/features/support/data/support_models.dart | 123 ++++++ .../support/data/support_repository.dart | 108 +++++ .../presentation/pages/support_new_page.dart | 365 +++++++++++++++++ .../presentation/pages/support_page.dart | 315 +++++++++++++++ .../pages/support_ticket_page.dart | 374 ++++++++++++++++++ .../support/presentation/support_style.dart | 59 +++ .../notification_routing_test.dart | 13 + .../support/support_diagnostics_test.dart | 82 ++++ .../support/support_repository_test.dart | 148 +++++++ 32 files changed, 2388 insertions(+), 23 deletions(-) create mode 100644 lib/features/support/data/support_diagnostics.dart create mode 100644 lib/features/support/data/support_models.dart create mode 100644 lib/features/support/data/support_repository.dart create mode 100644 lib/features/support/presentation/pages/support_new_page.dart create mode 100644 lib/features/support/presentation/pages/support_page.dart create mode 100644 lib/features/support/presentation/pages/support_ticket_page.dart create mode 100644 lib/features/support/presentation/support_style.dart create mode 100644 test/features/support/support_diagnostics_test.dart create mode 100644 test/features/support/support_repository_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 901b8819..259813e8 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -733,7 +733,6 @@ "date_format": "تنسيق التاريخ", "date_format_desc": "وإلّا فاللغة هي التي تحدّد ذلك.", "date_format_auto": "حسب اللغة", - "section_content": "المحتوى", "adult_content": "محتوى +18", "adult_content_desc": "إظهار العناوين والمصادر للبالغين في كل مكان — AniList وTMDB وCloudStream وAniyomi والمانغا والتورنت.", "adult_confirm_title": "هل عمرك 18 عامًا أو أكثر؟", @@ -2957,5 +2956,48 @@ "streak_done": "تم لليوم", "streak_tonight": "ينتهي الليلة", "streak_next": "{left} للوصول إلى شارة {target} يوم" + }, + "support": { + "entry_subtitle": "أبلغ عن مشكلة أو اقترح فكرة", + "intro_title": "كيف يمكننا مساعدتك؟", + "intro_body": "أخبرنا بما حدث من خطأ أو بما تودّ رؤيته. نقرأ كل رسالة ونرد عادةً خلال يوم.", + "new_request": "طلب جديد", + "my_requests": "طلباتك", + "empty": "لم تراسلنا بعد.", + "new_answer": "رد جديد", + "team": "دعم Sozo", + "category_title": "بخصوص ماذا؟", + "cat_playback": "تشغيل الفيديو", + "cat_account": "الحساب وتسجيل الدخول", + "cat_content": "المصادر والمحتوى", + "cat_bug": "شيء لا يعمل", + "cat_idea": "فكرة أو اقتراح", + "cat_other": "شيء آخر", + "message_label": "رسالتك", + "message_hint": "ماذا حدث؟ ماذا فعلت، وماذا توقعت، وماذا رأيت بدلاً من ذلك؟", + "contact_label": "وسيلة تواصل (اختياري)", + "contact_hint": "البريد الإلكتروني أو اسم المستخدم في Telegram", + "contact_note": "لم تسجّل الدخول — سيظهر ردنا هنا في «المساعدة والدعم». اترك وسيلة تواصل إن أردت الرد هناك أيضاً.", + "diag_title": "إرفاق معلومات التشخيص", + "diag_body": "إصدار التطبيق والنظام ونوع الشبكة وسجل المشغّل، بعد حذف كلمات المرور والرموز وملفات تعريف الارتباط. يساعدنا على إيجاد السبب أسرع.", + "diag_show": "عرض ما سيتم إرساله", + "diag_log_lines": "سجل المشغّل: {} سطر", + "send": "إرسال", + "sent": "تم الإرسال — سنرد هنا.", + "status_open": "بانتظار الرد", + "status_answered": "تم الرد", + "status_closed": "مغلق", + "reply_hint": "اكتب رسالة…", + "close": "إغلاق الطلب", + "close_confirm_title": "إغلاق هذا الطلب؟", + "close_confirm_body": "يمكنك الكتابة مجدداً في أي وقت — وهذا يعيد فتحه.", + "waiting_note": "وصلتنا رسالتك وسنرد هنا. إن كنت مسجّل الدخول فستصلك إشعار أيضاً.", + "closed_note": "هذا الطلب مغلق. الكتابة مجدداً تعيد فتحه.", + "contact_support": "راسل الدعم", + "error_generic": "حدث خطأ ما. حاول مرة أخرى.", + "error_too_many": "طلبات كثيرة الآن — انتظر قليلاً.", + "error_short": "يرجى كتابة بضع كلمات إضافية.", + "error_thread_full": "هذه المحادثة طويلة جداً — ابدأ طلباً جديداً.", + "error_not_found": "هذا الطلب لم يعد موجوداً." } } diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 450dc761..a49d98e0 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -2952,5 +2952,48 @@ "streak_done": "بۆ ئەمڕۆ تەواو", "streak_tonight": "ئەمشەو تەواو دەبێت", "streak_next": "{left}ی تر تا نیشانەی {target} ڕۆژە" + }, + "support": { + "entry_subtitle": "کێشەیەک ڕاپۆرت بکە یان بیرۆکەیەک پێشنیار بکە", + "intro_title": "چۆن یارمەتیت بدەین؟", + "intro_body": "پێمان بڵێ چی هەڵە ڕۆیشت یان حەز دەکەیت چی ببینیت. هەموو نامەیەک دەخوێنینەوە و زۆرجار لە ماوەی ڕۆژێکدا وەڵام دەدەینەوە.", + "new_request": "داواکاریی نوێ", + "my_requests": "داواکارییەکانت", + "empty": "هێشتا بۆمان نەنووسیوە.", + "new_answer": "وەڵامی نوێ", + "team": "پشتگیریی Sozo", + "category_title": "دەربارەی چییە؟", + "cat_playback": "لێدانی ڤیدیۆ", + "cat_account": "هەژمار و چوونەژوورەوە", + "cat_content": "سەرچاوە و ناوەڕۆک", + "cat_bug": "شتێک کار ناکات", + "cat_idea": "بیرۆکە یان پێشنیار", + "cat_other": "شتێکی تر", + "message_label": "نامەکەت", + "message_hint": "چی ڕوویدا؟ چیت کرد، چاوەڕێی چیت دەکرد و لە جیاتی ئەوە چیت بینی؟", + "contact_label": "پەیوەندی (ئارەزوومەندانە)", + "contact_hint": "ئیمەیڵ یان ناوی بەکارهێنەری Telegram", + "contact_note": "نەچوویتە ژوورەوە — وەڵامەکەمان لێرە لە «یارمەتی و پشتگیری» دەردەکەوێت. ئەگەر دەتەوێت لەوێش وەڵام وەربگریت، پەیوەندییەک دابنێ.", + "diag_title": "زانیاریی دەستنیشانکردن هاوپێچ بکە", + "diag_body": "وەشانی ئەپ، سیستەم، جۆری تۆڕ و تۆماری لێدەر — بێ وشەی نهێنی، تۆکن و کووکی. یارمەتیمان دەدات هۆکارەکە خێراتر بدۆزینەوە.", + "diag_show": "بینینی ئەوەی دەنێردرێت", + "diag_log_lines": "تۆماری لێدەر: {} دێڕ", + "send": "ناردن", + "sent": "نێردرا — لێرە وەڵام دەدەینەوە.", + "status_open": "چاوەڕێی وەڵام", + "status_answered": "وەڵام دراوەتەوە", + "status_closed": "داخراوە", + "reply_hint": "نامەیەک بنووسە…", + "close": "داخستنی داواکاری", + "close_confirm_title": "ئەم داواکارییە دابخرێت؟", + "close_confirm_body": "هەر کاتێک دەتوانیت دیسان بنووسیت — ئەمە دووبارە دەیکاتەوە.", + "waiting_note": "نامەکەتمان وەرگرت و لێرە وەڵام دەدەینەوە. ئەگەر چوویتە ژوورەوە، ئاگادارکردنەوەش دێت.", + "closed_note": "ئەم داواکارییە داخراوە. دووبارە نووسین دەیکاتەوە.", + "contact_support": "بۆ پشتگیری بنووسە", + "error_generic": "شتێک هەڵە ڕۆیشت. دووبارە هەوڵ بدەرەوە.", + "error_too_many": "ئێستا داواکاری زۆرە — کەمێک چاوەڕێ بکە.", + "error_short": "تکایە چەند وشەیەکی تر بنووسە.", + "error_thread_full": "ئەم گفتوگۆیە زۆر درێژە — داواکارییەکی نوێ بکەرەوە.", + "error_not_found": "ئەم داواکارییە ئیتر بوونی نییە." } } diff --git a/assets/translations/de.json b/assets/translations/de.json index a7548875..1fbb7dbb 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -601,7 +601,7 @@ "sign_out": "Abmelden", "sign_out_confirm": "Möchtest du dich wirklich abmelden?", "section_providers": "Quellen", - "section_content": "INHALTE", + "section_content": "Inhalte", "sources_row": "Erweiterungsquellen", "sources_row_subtitle": "Alle Quellen durchsehen und weitere installieren", "provider": "Quelle", @@ -733,7 +733,6 @@ "date_format": "Datumsformat", "date_format_desc": "Sonst entscheidet das die Sprache.", "date_format_auto": "Der Sprache folgen", - "section_content": "Inhalte", "adult_content": "18+-Inhalte", "adult_content_desc": "Inhalte und Quellen für Erwachsene überall zeigen — AniList, TMDB, CloudStream, Aniyomi, Manga und Torrents.", "adult_confirm_title": "Bist du 18 oder älter?", @@ -2953,5 +2952,48 @@ "streak_done": "Heute erledigt", "streak_tonight": "Endet heute", "streak_next": "Noch {left} bis zum {target}-Tage-Abzeichen" + }, + "support": { + "entry_subtitle": "Problem melden oder Idee vorschlagen", + "intro_title": "Wie können wir helfen?", + "intro_body": "Erzähl uns, was schiefgelaufen ist oder was du dir wünschst. Wir lesen jede Nachricht und antworten meist innerhalb eines Tages.", + "new_request": "Neue Anfrage", + "my_requests": "Deine Anfragen", + "empty": "Du hast uns noch nicht geschrieben.", + "new_answer": "Neue Antwort", + "team": "Sozo Support", + "category_title": "Worum geht es?", + "cat_playback": "Videowiedergabe", + "cat_account": "Konto & Anmeldung", + "cat_content": "Quellen & Inhalte", + "cat_bug": "Etwas funktioniert nicht", + "cat_idea": "Idee oder Vorschlag", + "cat_other": "Etwas anderes", + "message_label": "Deine Nachricht", + "message_hint": "Was ist passiert? Was hast du getan, was hast du erwartet und was hast du stattdessen gesehen?", + "contact_label": "Kontakt (optional)", + "contact_hint": "E-Mail oder Telegram-Name", + "contact_note": "Du bist nicht angemeldet — unsere Antwort erscheint hier unter „Hilfe & Support“. Hinterlass einen Kontakt, wenn du sie auch dort bekommen möchtest.", + "diag_title": "Diagnosedaten anhängen", + "diag_body": "App-Version, System, Netzwerktyp und das Player-Protokoll — ohne Passwörter, Tokens und Cookies. Hilft uns, die Ursache schneller zu finden.", + "diag_show": "Anzeigen, was gesendet wird", + "diag_log_lines": "Player-Protokoll: {} Zeilen", + "send": "Senden", + "sent": "Gesendet — wir antworten hier.", + "status_open": "Wartet auf Antwort", + "status_answered": "Beantwortet", + "status_closed": "Geschlossen", + "reply_hint": "Nachricht schreiben…", + "close": "Anfrage schließen", + "close_confirm_title": "Diese Anfrage schließen?", + "close_confirm_body": "Du kannst jederzeit wieder schreiben — dann wird sie neu geöffnet.", + "waiting_note": "Wir haben deine Nachricht und antworten hier. Wenn du angemeldet bist, bekommst du auch eine Benachrichtigung.", + "closed_note": "Diese Anfrage ist geschlossen. Eine neue Nachricht öffnet sie wieder.", + "contact_support": "Support kontaktieren", + "error_generic": "Etwas ist schiefgelaufen. Versuch es noch einmal.", + "error_too_many": "Gerade zu viele Anfragen — warte bitte kurz.", + "error_short": "Schreib bitte noch ein paar Worte mehr.", + "error_thread_full": "Diese Unterhaltung ist zu lang — starte bitte eine neue Anfrage.", + "error_not_found": "Diese Anfrage gibt es nicht mehr." } } diff --git a/assets/translations/en.json b/assets/translations/en.json index 75ecc0e6..82a459e8 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -601,7 +601,7 @@ "sign_out": "Sign Out", "sign_out_confirm": "Are you sure you want to sign out?", "section_providers": "Sources", - "section_content": "CONTENT", + "section_content": "Content", "sources_row": "Extension sources", "sources_row_subtitle": "Browse every source, and install more", "provider": "Source", @@ -735,7 +735,6 @@ "date_format": "Date format", "date_format_desc": "The language decides this unless you say otherwise.", "date_format_auto": "Follow the language", - "section_content": "Content", "adult_content": "18+ content", "adult_content_desc": "Show adult titles and sources everywhere — AniList, TMDB, CloudStream, Aniyomi, manga and torrents.", "adult_confirm_title": "Are you 18 or older?", @@ -2953,5 +2952,48 @@ "streak_done": "Done for today", "streak_tonight": "Ends tonight", "streak_next": "{left} more to the {target}-day badge" + }, + "support": { + "entry_subtitle": "Report a problem or suggest an idea", + "intro_title": "How can we help?", + "intro_body": "Tell us what went wrong or what you'd like to see. We read every message and usually answer within a day.", + "new_request": "New request", + "my_requests": "Your requests", + "empty": "You haven't written to us yet.", + "new_answer": "New answer", + "team": "Sozo Support", + "category_title": "What is it about?", + "cat_playback": "Video playback", + "cat_account": "Account & sign-in", + "cat_content": "Sources & content", + "cat_bug": "Something is broken", + "cat_idea": "Idea or suggestion", + "cat_other": "Something else", + "message_label": "Your message", + "message_hint": "What happened? What did you do, what did you expect, and what did you see instead?", + "contact_label": "Contact (optional)", + "contact_hint": "Email or Telegram username", + "contact_note": "You're not signed in — our answer will appear here, in Help & Support. Leave a contact if you'd also like a reply there.", + "diag_title": "Attach diagnostic info", + "diag_body": "App version, system, network type and the player log, with passwords, tokens and cookies removed. It helps us find the cause faster.", + "diag_show": "See what will be sent", + "diag_log_lines": "Player log: {} lines", + "send": "Send", + "sent": "Sent — we'll answer here.", + "status_open": "Waiting for an answer", + "status_answered": "Answered", + "status_closed": "Closed", + "reply_hint": "Write a message…", + "close": "Close request", + "close_confirm_title": "Close this request?", + "close_confirm_body": "You can write again anytime — that reopens it.", + "waiting_note": "We've got your message and will answer here. If you're signed in, you'll also get a notification.", + "closed_note": "This request is closed. Writing again reopens it.", + "contact_support": "Write to support", + "error_generic": "Something went wrong. Please try again.", + "error_too_many": "Too many requests right now — please wait a little.", + "error_short": "Please write a few more words.", + "error_thread_full": "This conversation is too long — please start a new request.", + "error_not_found": "This request no longer exists." } } diff --git a/assets/translations/es.json b/assets/translations/es.json index 6c0b820f..15924267 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -601,7 +601,7 @@ "sign_out": "Cerrar sesión", "sign_out_confirm": "¿Seguro que quieres cerrar sesión?", "section_providers": "Fuentes", - "section_content": "CONTENIDO", + "section_content": "Contenido", "sources_row": "Fuentes de extensión", "sources_row_subtitle": "Explorar todas las fuentes e instalar más", "provider": "Fuente", @@ -733,7 +733,6 @@ "date_format": "Formato de fecha", "date_format_desc": "Si no eliges, lo decide el idioma.", "date_format_auto": "Seguir el idioma", - "section_content": "Contenido", "adult_content": "Contenido +18", "adult_content_desc": "Mostrar títulos y fuentes para adultos en todas partes — AniList, TMDB, CloudStream, Aniyomi, manga y torrents.", "adult_confirm_title": "¿Tienes 18 años o más?", @@ -2953,5 +2952,48 @@ "streak_done": "Hecho por hoy", "streak_tonight": "Acaba hoy", "streak_next": "{left} más para la insignia de {target} días" + }, + "support": { + "entry_subtitle": "Informa de un problema o sugiere una idea", + "intro_title": "¿Cómo podemos ayudarte?", + "intro_body": "Cuéntanos qué salió mal o qué te gustaría ver. Leemos cada mensaje y solemos responder en un día.", + "new_request": "Nueva solicitud", + "my_requests": "Tus solicitudes", + "empty": "Aún no nos has escrito.", + "new_answer": "Nueva respuesta", + "team": "Soporte de Sozo", + "category_title": "¿De qué se trata?", + "cat_playback": "Reproducción de video", + "cat_account": "Cuenta e inicio de sesión", + "cat_content": "Fuentes y contenido", + "cat_bug": "Algo no funciona", + "cat_idea": "Idea o sugerencia", + "cat_other": "Otra cosa", + "message_label": "Tu mensaje", + "message_hint": "¿Qué pasó? ¿Qué hiciste, qué esperabas y qué viste en su lugar?", + "contact_label": "Contacto (opcional)", + "contact_hint": "Email o usuario de Telegram", + "contact_note": "No has iniciado sesión: nuestra respuesta aparecerá aquí, en Ayuda y soporte. Deja un contacto si también quieres recibirla allí.", + "diag_title": "Adjuntar información de diagnóstico", + "diag_body": "Versión de la app, sistema, tipo de red y el registro del reproductor, sin contraseñas, tokens ni cookies. Nos ayuda a encontrar la causa antes.", + "diag_show": "Ver qué se enviará", + "diag_log_lines": "Registro del reproductor: {} líneas", + "send": "Enviar", + "sent": "Enviado: responderemos aquí.", + "status_open": "Esperando respuesta", + "status_answered": "Respondida", + "status_closed": "Cerrada", + "reply_hint": "Escribe un mensaje…", + "close": "Cerrar solicitud", + "close_confirm_title": "¿Cerrar esta solicitud?", + "close_confirm_body": "Puedes volver a escribir cuando quieras: eso la reabre.", + "waiting_note": "Recibimos tu mensaje y responderemos aquí. Si iniciaste sesión, también te llegará una notificación.", + "closed_note": "Esta solicitud está cerrada. Escribir de nuevo la reabre.", + "contact_support": "Escribir a soporte", + "error_generic": "Algo salió mal. Inténtalo de nuevo.", + "error_too_many": "Demasiadas solicitudes ahora: espera un poco.", + "error_short": "Escribe unas palabras más, por favor.", + "error_thread_full": "Esta conversación es demasiado larga: abre una nueva solicitud.", + "error_not_found": "Esta solicitud ya no existe." } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 427a4c76..cb8cec3c 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -601,7 +601,7 @@ "sign_out": "Se déconnecter", "sign_out_confirm": "Veux-tu vraiment te déconnecter ?", "section_providers": "Sources", - "section_content": "CONTENU", + "section_content": "Contenu", "sources_row": "Sources d’extension", "sources_row_subtitle": "Parcourir toutes les sources et en installer", "provider": "Source", @@ -733,7 +733,6 @@ "date_format": "Format de date", "date_format_desc": "Sinon, c'est la langue qui décide.", "date_format_auto": "Suivre la langue", - "section_content": "Contenu", "adult_content": "Contenu 18+", "adult_content_desc": "Afficher partout les titres et sources pour adultes — AniList, TMDB, CloudStream, Aniyomi, manga et torrents.", "adult_confirm_title": "Avez-vous 18 ans ou plus ?", @@ -2953,5 +2952,48 @@ "streak_done": "Fait pour aujourd’hui", "streak_tonight": "S’arrête ce soir", "streak_next": "Encore {left} pour le badge {target} jours" + }, + "support": { + "entry_subtitle": "Signaler un problème ou proposer une idée", + "intro_title": "Comment pouvons-nous t’aider ?", + "intro_body": "Dis-nous ce qui n’a pas marché ou ce que tu aimerais voir. On lit chaque message et on répond en général sous un jour.", + "new_request": "Nouvelle demande", + "my_requests": "Tes demandes", + "empty": "Tu ne nous as pas encore écrit.", + "new_answer": "Nouvelle réponse", + "team": "Support Sozo", + "category_title": "De quoi s’agit-il ?", + "cat_playback": "Lecture vidéo", + "cat_account": "Compte et connexion", + "cat_content": "Sources et contenu", + "cat_bug": "Quelque chose ne marche pas", + "cat_idea": "Idée ou suggestion", + "cat_other": "Autre chose", + "message_label": "Ton message", + "message_hint": "Que s’est-il passé ? Qu’as-tu fait, qu’attendais-tu et qu’as-tu vu à la place ?", + "contact_label": "Contact (facultatif)", + "contact_hint": "E-mail ou pseudo Telegram", + "contact_note": "Tu n’es pas connecté — notre réponse apparaîtra ici, dans Aide et support. Laisse un contact si tu veux aussi la recevoir là-bas.", + "diag_title": "Joindre les infos de diagnostic", + "diag_body": "Version de l’app, système, type de réseau et journal du lecteur, sans mots de passe, jetons ni cookies. Ça nous aide à trouver la cause plus vite.", + "diag_show": "Voir ce qui sera envoyé", + "diag_log_lines": "Journal du lecteur : {} lignes", + "send": "Envoyer", + "sent": "Envoyé — on te répond ici.", + "status_open": "En attente de réponse", + "status_answered": "Répondue", + "status_closed": "Fermée", + "reply_hint": "Écris un message…", + "close": "Fermer la demande", + "close_confirm_title": "Fermer cette demande ?", + "close_confirm_body": "Tu peux réécrire quand tu veux — ça la rouvre.", + "waiting_note": "On a bien reçu ton message et on répondra ici. Si tu es connecté, tu recevras aussi une notification.", + "closed_note": "Cette demande est fermée. Réécrire la rouvre.", + "contact_support": "Écrire au support", + "error_generic": "Un problème est survenu. Réessaie.", + "error_too_many": "Trop de demandes pour l’instant — patiente un peu.", + "error_short": "Écris quelques mots de plus, s’il te plaît.", + "error_thread_full": "Cette conversation est trop longue — ouvre une nouvelle demande.", + "error_not_found": "Cette demande n’existe plus." } } diff --git a/assets/translations/id.json b/assets/translations/id.json index e8af63a8..37bdc46a 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -601,7 +601,7 @@ "sign_out": "Keluar", "sign_out_confirm": "Yakin ingin keluar?", "section_providers": "Sumber", - "section_content": "KONTEN", + "section_content": "Konten", "sources_row": "Sumber ekstensi", "sources_row_subtitle": "Jelajahi semua sumber dan pasang lainnya", "provider": "Sumber", @@ -733,7 +733,6 @@ "date_format": "Format tanggal", "date_format_desc": "Jika tidak diatur, bahasa yang menentukan.", "date_format_auto": "Ikuti bahasa", - "section_content": "Konten", "adult_content": "Konten 18+", "adult_content_desc": "Tampilkan judul dan sumber dewasa di mana saja — AniList, TMDB, CloudStream, Aniyomi, manga, dan torrent.", "adult_confirm_title": "Apakah kamu berusia 18 tahun ke atas?", @@ -2953,5 +2952,48 @@ "streak_done": "Selesai hari ini", "streak_tonight": "Berakhir malam ini", "streak_next": "{left} lagi menuju lencana {target} hari" + }, + "support": { + "entry_subtitle": "Laporkan masalah atau usulkan ide", + "intro_title": "Ada yang bisa kami bantu?", + "intro_body": "Ceritakan apa yang salah atau apa yang ingin kamu lihat. Kami membaca setiap pesan dan biasanya menjawab dalam sehari.", + "new_request": "Permintaan baru", + "my_requests": "Permintaanmu", + "empty": "Kamu belum menulis ke kami.", + "new_answer": "Jawaban baru", + "team": "Dukungan Sozo", + "category_title": "Tentang apa?", + "cat_playback": "Pemutaran video", + "cat_account": "Akun & masuk", + "cat_content": "Sumber & konten", + "cat_bug": "Ada yang tidak berfungsi", + "cat_idea": "Ide atau saran", + "cat_other": "Hal lain", + "message_label": "Pesanmu", + "message_hint": "Apa yang terjadi? Apa yang kamu lakukan, apa yang kamu harapkan, dan apa yang kamu lihat?", + "contact_label": "Kontak (opsional)", + "contact_hint": "Email atau username Telegram", + "contact_note": "Kamu belum masuk — jawaban kami akan muncul di sini, di Bantuan & Dukungan. Tinggalkan kontak kalau ingin jawaban di sana juga.", + "diag_title": "Lampirkan info diagnostik", + "diag_body": "Versi aplikasi, sistem, jenis jaringan, dan log pemutar, tanpa kata sandi, token, dan cookie. Membantu kami menemukan penyebabnya lebih cepat.", + "diag_show": "Lihat apa yang akan dikirim", + "diag_log_lines": "Log pemutar: {} baris", + "send": "Kirim", + "sent": "Terkirim — kami akan menjawab di sini.", + "status_open": "Menunggu jawaban", + "status_answered": "Dijawab", + "status_closed": "Ditutup", + "reply_hint": "Tulis pesan…", + "close": "Tutup permintaan", + "close_confirm_title": "Tutup permintaan ini?", + "close_confirm_body": "Kamu bisa menulis lagi kapan saja — itu akan membukanya kembali.", + "waiting_note": "Pesanmu sudah kami terima dan akan kami jawab di sini. Kalau kamu sudah masuk, kamu juga akan dapat notifikasi.", + "closed_note": "Permintaan ini ditutup. Menulis lagi akan membukanya kembali.", + "contact_support": "Hubungi dukungan", + "error_generic": "Terjadi kesalahan. Coba lagi.", + "error_too_many": "Terlalu banyak permintaan — tunggu sebentar.", + "error_short": "Tulis beberapa kata lagi, ya.", + "error_thread_full": "Percakapan ini terlalu panjang — buat permintaan baru.", + "error_not_found": "Permintaan ini sudah tidak ada." } } diff --git a/assets/translations/ku.json b/assets/translations/ku.json index c148dc09..c75cb046 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -2952,5 +2952,48 @@ "streak_done": "Ji bo îro temam e", "streak_tonight": "Îşev diqede", "streak_next": "{left} yên din heta nîşana {target} rojan" + }, + "support": { + "entry_subtitle": "Pirsgirêkekê ragihîne an ramanekê pêşniyar bike", + "intro_title": "Em çawa dikarin alîkariya te bikin?", + "intro_body": "Ji me re bibêje çi xelet çû an tu dixwazî çi bibînî. Em her peyamê dixwînin û bi gelemperî di rojekê de bersiv didin.", + "new_request": "Daxwaza nû", + "my_requests": "Daxwazên te", + "empty": "Te hîn ji me re nenivîsandiye.", + "new_answer": "Bersiva nû", + "team": "Piştgiriya Sozo", + "category_title": "Li ser çi ye?", + "cat_playback": "Lêdana vîdyoyê", + "cat_account": "Hesab û têketin", + "cat_content": "Çavkanî û naverok", + "cat_bug": "Tiştek naxebite", + "cat_idea": "Raman an pêşniyar", + "cat_other": "Tiştekî din", + "message_label": "Peyama te", + "message_hint": "Çi qewimî? Te çi kir, te çi hêvî dikir û li şûna wê te çi dît?", + "contact_label": "Têkilî (vebijarkî)", + "contact_hint": "E-name an navê bikarhêner ê Telegramê", + "contact_note": "Tu têneketî — bersiva me dê li vir, di Alîkarî û Piştgiriyê de xuya bibe. Heke tu dixwazî li wir jî bersiv bistînî, têkiliyekê bihêle.", + "diag_title": "Agahiyên teşhîsê pê ve bike", + "diag_body": "Guhertoya sepanê, pergal, cureyê torê û tomara lêdarê — bê şîfre, token û cookie. Alîkariya me dike ku em sedemê zûtir bibînin.", + "diag_show": "Bibîne çi dê were şandin", + "diag_log_lines": "Tomara lêdarê: {} rêz", + "send": "Bişîne", + "sent": "Hat şandin — em ê li vir bersiv bidin.", + "status_open": "Li benda bersivê", + "status_answered": "Bersiv hat dayîn", + "status_closed": "Girtî", + "reply_hint": "Peyamekê binivîse…", + "close": "Daxwazê bigire", + "close_confirm_title": "Ev daxwaz were girtin?", + "close_confirm_body": "Tu dikarî her dem dîsa binivîsî — ev wê ji nû ve vedike.", + "waiting_note": "Me peyama te stand û em ê li vir bersiv bidin. Heke tu ketibî hesabê xwe, agahdariyek jî dê were.", + "closed_note": "Ev daxwaz girtî ye. Dîsa nivîsandin wê ji nû ve vedike.", + "contact_support": "Ji piştgiriyê re binivîse", + "error_generic": "Tiştek xelet çû. Dîsa biceribîne.", + "error_too_many": "Niha gelek daxwaz hene — hinekî bisekine.", + "error_short": "Ji kerema xwe çend peyvên din binivîse.", + "error_thread_full": "Ev axaftin pir dirêj e — daxwazeke nû veke.", + "error_not_found": "Ev daxwaz êdî tune ye." } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 94d5c263..eb20315d 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -601,7 +601,7 @@ "sign_out": "Uitloggen", "sign_out_confirm": "Weet je zeker dat je wilt uitloggen?", "section_providers": "Bronnen", - "section_content": "INHOUD", + "section_content": "Inhoud", "sources_row": "Extensiebronnen", "sources_row_subtitle": "Alle bronnen bekijken en meer installeren", "provider": "Bron", @@ -733,7 +733,6 @@ "date_format": "Datumnotatie", "date_format_desc": "Anders bepaalt de taal dit.", "date_format_auto": "Volg de taal", - "section_content": "Inhoud", "adult_content": "18+-inhoud", "adult_content_desc": "Titels en bronnen voor volwassenen overal tonen — AniList, TMDB, CloudStream, Aniyomi, manga en torrents.", "adult_confirm_title": "Ben je 18 of ouder?", @@ -2953,5 +2952,48 @@ "streak_done": "Klaar voor vandaag", "streak_tonight": "Eindigt vannacht", "streak_next": "Nog {left} tot de {target}-dagenbadge" + }, + "support": { + "entry_subtitle": "Meld een probleem of stel iets voor", + "intro_title": "Waarmee kunnen we helpen?", + "intro_body": "Vertel ons wat er misging of wat je graag zou zien. We lezen elk bericht en antwoorden meestal binnen een dag.", + "new_request": "Nieuwe vraag", + "my_requests": "Jouw vragen", + "empty": "Je hebt ons nog niet geschreven.", + "new_answer": "Nieuw antwoord", + "team": "Sozo Support", + "category_title": "Waar gaat het over?", + "cat_playback": "Video afspelen", + "cat_account": "Account & inloggen", + "cat_content": "Bronnen & inhoud", + "cat_bug": "Er werkt iets niet", + "cat_idea": "Idee of suggestie", + "cat_other": "Iets anders", + "message_label": "Je bericht", + "message_hint": "Wat gebeurde er? Wat deed je, wat verwachtte je en wat zag je in plaats daarvan?", + "contact_label": "Contact (optioneel)", + "contact_hint": "E-mail of Telegram-gebruikersnaam", + "contact_note": "Je bent niet ingelogd — ons antwoord verschijnt hier, bij Hulp & Support. Laat een contact achter als je ook daar een antwoord wilt.", + "diag_title": "Diagnose-info bijvoegen", + "diag_body": "App-versie, systeem, netwerktype en het spelerlogboek, zonder wachtwoorden, tokens en cookies. Zo vinden we de oorzaak sneller.", + "diag_show": "Bekijk wat er wordt verstuurd", + "diag_log_lines": "Spelerlogboek: {} regels", + "send": "Versturen", + "sent": "Verstuurd — we antwoorden hier.", + "status_open": "Wacht op antwoord", + "status_answered": "Beantwoord", + "status_closed": "Gesloten", + "reply_hint": "Schrijf een bericht…", + "close": "Vraag sluiten", + "close_confirm_title": "Deze vraag sluiten?", + "close_confirm_body": "Je kunt altijd opnieuw schrijven — dan gaat hij weer open.", + "waiting_note": "We hebben je bericht en antwoorden hier. Als je bent ingelogd, krijg je ook een melding.", + "closed_note": "Deze vraag is gesloten. Opnieuw schrijven opent hem weer.", + "contact_support": "Support schrijven", + "error_generic": "Er ging iets mis. Probeer het opnieuw.", + "error_too_many": "Te veel verzoeken — wacht even.", + "error_short": "Schrijf nog een paar woorden.", + "error_thread_full": "Dit gesprek is te lang — begin een nieuwe vraag.", + "error_not_found": "Deze vraag bestaat niet meer." } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 43dd20ad..4a72e7d0 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -601,7 +601,7 @@ "sign_out": "Sair", "sign_out_confirm": "Tem certeza de que quer sair?", "section_providers": "Fontes", - "section_content": "CONTEÚDO", + "section_content": "Conteúdo", "sources_row": "Fontes de extensão", "sources_row_subtitle": "Explorar todas as fontes e instalar mais", "provider": "Fonte", @@ -733,7 +733,6 @@ "date_format": "Formato da data", "date_format_desc": "Caso contrário, quem decide é o idioma.", "date_format_auto": "Seguir o idioma", - "section_content": "Conteúdo", "adult_content": "Conteúdo +18", "adult_content_desc": "Mostrar títulos e fontes adultos em todo lugar — AniList, TMDB, CloudStream, Aniyomi, mangá e torrents.", "adult_confirm_title": "Você tem 18 anos ou mais?", @@ -2953,5 +2952,48 @@ "streak_done": "Feito por hoje", "streak_tonight": "Acaba hoje", "streak_next": "Mais {left} para a insígnia de {target} dias" + }, + "support": { + "entry_subtitle": "Relate um problema ou sugira uma ideia", + "intro_title": "Como podemos ajudar?", + "intro_body": "Conte o que deu errado ou o que você gostaria de ver. Lemos todas as mensagens e costumamos responder em um dia.", + "new_request": "Nova solicitação", + "my_requests": "Suas solicitações", + "empty": "Você ainda não nos escreveu.", + "new_answer": "Nova resposta", + "team": "Suporte Sozo", + "category_title": "Sobre o que é?", + "cat_playback": "Reprodução de vídeo", + "cat_account": "Conta e login", + "cat_content": "Fontes e conteúdo", + "cat_bug": "Algo não funciona", + "cat_idea": "Ideia ou sugestão", + "cat_other": "Outra coisa", + "message_label": "Sua mensagem", + "message_hint": "O que aconteceu? O que você fez, o que esperava e o que viu no lugar?", + "contact_label": "Contato (opcional)", + "contact_hint": "E-mail ou usuário do Telegram", + "contact_note": "Você não entrou na conta — nossa resposta vai aparecer aqui, em Ajuda e suporte. Deixe um contato se quiser receber a resposta lá também.", + "diag_title": "Anexar informações de diagnóstico", + "diag_body": "Versão do app, sistema, tipo de rede e o registro do player, sem senhas, tokens e cookies. Ajuda a achar a causa mais rápido.", + "diag_show": "Ver o que será enviado", + "diag_log_lines": "Registro do player: {} linhas", + "send": "Enviar", + "sent": "Enviado — vamos responder aqui.", + "status_open": "Aguardando resposta", + "status_answered": "Respondida", + "status_closed": "Encerrada", + "reply_hint": "Escreva uma mensagem…", + "close": "Encerrar solicitação", + "close_confirm_title": "Encerrar esta solicitação?", + "close_confirm_body": "Você pode escrever de novo quando quiser — isso a reabre.", + "waiting_note": "Recebemos sua mensagem e vamos responder aqui. Se você entrou na conta, também vai receber uma notificação.", + "closed_note": "Esta solicitação está encerrada. Escrever de novo a reabre.", + "contact_support": "Falar com o suporte", + "error_generic": "Algo deu errado. Tente de novo.", + "error_too_many": "Muitas solicitações agora — espere um pouco.", + "error_short": "Escreva mais algumas palavras, por favor.", + "error_thread_full": "Esta conversa ficou longa demais — abra uma nova solicitação.", + "error_not_found": "Esta solicitação não existe mais." } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 8d1b98f4..d6b7ce21 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -601,7 +601,7 @@ "sign_out": "Выйти", "sign_out_confirm": "Вы уверены, что хотите выйти?", "section_providers": "Источники", - "section_content": "КОНТЕНТ", + "section_content": "Контент", "sources_row": "Источники расширений", "sources_row_subtitle": "Просмотр всех источников и установка новых", "provider": "Источник", @@ -735,7 +735,6 @@ "date_format": "Формат даты", "date_format_desc": "Иначе это решает язык приложения.", "date_format_auto": "Как в языке", - "section_content": "Контент", "adult_content": "Контент 18+", "adult_content_desc": "Показывать взрослые тайтлы и источники везде — AniList, TMDB, CloudStream, Aniyomi, манга и торренты.", "adult_confirm_title": "Вам есть 18 лет?", @@ -2954,5 +2953,48 @@ "streak_done": "На сегодня всё", "streak_tonight": "Прервётся сегодня", "streak_next": "До значка «{target} дней»: ещё {left}" + }, + "support": { + "entry_subtitle": "Сообщить о проблеме или предложить идею", + "intro_title": "Чем мы можем помочь?", + "intro_body": "Расскажите, что пошло не так или что вы хотели бы увидеть. Мы читаем каждое сообщение и обычно отвечаем в течение дня.", + "new_request": "Новое обращение", + "my_requests": "Ваши обращения", + "empty": "Вы ещё не писали нам.", + "new_answer": "Новый ответ", + "team": "Поддержка Sozo", + "category_title": "О чём обращение?", + "cat_playback": "Воспроизведение видео", + "cat_account": "Аккаунт и вход", + "cat_content": "Источники и контент", + "cat_bug": "Что-то не работает", + "cat_idea": "Идея или предложение", + "cat_other": "Другое", + "message_label": "Ваше сообщение", + "message_hint": "Что случилось? Что вы делали, что ожидали и что увидели вместо этого?", + "contact_label": "Контакт (необязательно)", + "contact_hint": "Email или имя в Telegram", + "contact_note": "Вы не вошли в аккаунт — ответ появится здесь, в разделе «Помощь и поддержка». Оставьте контакт, если хотите получить ответ и туда.", + "diag_title": "Приложить диагностику", + "diag_body": "Версия приложения, система, тип сети и журнал плеера — без паролей, токенов и cookie. Помогает быстрее найти причину.", + "diag_show": "Что будет отправлено", + "diag_log_lines": "Журнал плеера: {} строк", + "send": "Отправить", + "sent": "Отправлено — ответим здесь.", + "status_open": "Ждёт ответа", + "status_answered": "Отвечено", + "status_closed": "Закрыто", + "reply_hint": "Напишите сообщение…", + "close": "Закрыть обращение", + "close_confirm_title": "Закрыть обращение?", + "close_confirm_body": "Вы можете написать снова в любое время — оно откроется заново.", + "waiting_note": "Мы получили ваше сообщение и ответим здесь. Если вы вошли в аккаунт, придёт и уведомление.", + "closed_note": "Обращение закрыто. Новое сообщение откроет его заново.", + "contact_support": "Написать в поддержку", + "error_generic": "Что-то пошло не так. Попробуйте ещё раз.", + "error_too_many": "Слишком много запросов — подождите немного.", + "error_short": "Пожалуйста, напишите чуть подробнее.", + "error_thread_full": "Переписка слишком длинная — создайте новое обращение.", + "error_not_found": "Это обращение больше не существует." } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index fca60de6..e66f0029 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -601,7 +601,7 @@ "sign_out": "Çıkış yap", "sign_out_confirm": "Çıkmak istediğine emin misin?", "section_providers": "Kaynaklar", - "section_content": "İÇERİK", + "section_content": "İçerik", "sources_row": "Eklenti kaynakları", "sources_row_subtitle": "Tüm kaynakları gözat ve yenilerini kur", "provider": "Kaynak", @@ -733,7 +733,6 @@ "date_format": "Tarih biçimi", "date_format_desc": "Aksi hâlde bunu dil belirler.", "date_format_auto": "Dile uy", - "section_content": "İçerik", "adult_content": "18+ içerik", "adult_content_desc": "Yetişkin içerikleri ve kaynakları her yerde göster — AniList, TMDB, CloudStream, Aniyomi, manga ve torrentler.", "adult_confirm_title": "18 yaşında veya daha büyük müsünüz?", @@ -2953,5 +2952,48 @@ "streak_done": "Bugünlük tamam", "streak_tonight": "Bu gece bitiyor", "streak_next": "{target} günlük rozete {left} gün" + }, + "support": { + "entry_subtitle": "Bir sorun bildir veya fikir öner", + "intro_title": "Nasıl yardımcı olabiliriz?", + "intro_body": "Neyin ters gittiğini ya da ne görmek istediğini anlat. Her mesajı okuyoruz ve genellikle bir gün içinde yanıtlıyoruz.", + "new_request": "Yeni talep", + "my_requests": "Taleplerin", + "empty": "Bize henüz yazmadın.", + "new_answer": "Yeni yanıt", + "team": "Sozo Destek", + "category_title": "Ne hakkında?", + "cat_playback": "Video oynatma", + "cat_account": "Hesap ve giriş", + "cat_content": "Kaynaklar ve içerik", + "cat_bug": "Bir şey çalışmıyor", + "cat_idea": "Fikir veya öneri", + "cat_other": "Başka bir şey", + "message_label": "Mesajın", + "message_hint": "Ne oldu? Ne yaptın, ne bekliyordun ve bunun yerine ne gördün?", + "contact_label": "İletişim (isteğe bağlı)", + "contact_hint": "E-posta veya Telegram kullanıcı adı", + "contact_note": "Giriş yapmadın — yanıtımız burada, Yardım ve Destek bölümünde görünecek. Oradan da yanıt almak istersen bir iletişim bilgisi bırak.", + "diag_title": "Tanılama bilgisi ekle", + "diag_body": "Uygulama sürümü, sistem, ağ türü ve oynatıcı kaydı; şifreler, token’lar ve çerezler çıkarılmış olarak. Nedeni daha hızlı bulmamıza yardım eder.", + "diag_show": "Nelerin gönderileceğini gör", + "diag_log_lines": "Oynatıcı kaydı: {} satır", + "send": "Gönder", + "sent": "Gönderildi — burada yanıtlayacağız.", + "status_open": "Yanıt bekleniyor", + "status_answered": "Yanıtlandı", + "status_closed": "Kapalı", + "reply_hint": "Bir mesaj yaz…", + "close": "Talebi kapat", + "close_confirm_title": "Bu talep kapatılsın mı?", + "close_confirm_body": "İstediğin zaman yeniden yazabilirsin — bu, talebi yeniden açar.", + "waiting_note": "Mesajını aldık, burada yanıtlayacağız. Giriş yaptıysan bildirim de gelecek.", + "closed_note": "Bu talep kapalı. Yeniden yazmak onu açar.", + "contact_support": "Desteğe yaz", + "error_generic": "Bir şeyler ters gitti. Tekrar dene.", + "error_too_many": "Şu an çok fazla istek var — biraz bekle.", + "error_short": "Lütfen birkaç kelime daha yaz.", + "error_thread_full": "Bu konuşma çok uzun — yeni bir talep aç.", + "error_not_found": "Bu talep artık mevcut değil." } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index d2d53e27..b768c3ed 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -601,7 +601,7 @@ "sign_out": "Chiqish", "sign_out_confirm": "Hisobdan chiqmoqchimisiz?", "section_providers": "Manbalar", - "section_content": "KONTENT", + "section_content": "Kontent", "sources_row": "Kengaytma manbalari", "sources_row_subtitle": "Har bir manbani ko'rish va yangisini o'rnatish", "provider": "Manba", @@ -735,7 +735,6 @@ "date_format": "Sana formati", "date_format_desc": "Aks holda buni til sozlamasi belgilaydi.", "date_format_auto": "Tilga moslash", - "section_content": "Kontent", "adult_content": "18+ kontent", "adult_content_desc": "Kattalar uchun kontent va manbalarni hamma joyda ko‘rsatish — AniList, TMDB, CloudStream, Aniyomi, manga va torrentlar.", "adult_confirm_title": "Yoshingiz 18 dan kattami?", @@ -2953,5 +2952,48 @@ "streak_done": "Bugun bajarildi", "streak_tonight": "Bugun tugaydi", "streak_next": "{target} kunlik yutuqqa {left} kun" + }, + "support": { + "entry_subtitle": "Muammo haqida yozing yoki taklif bering", + "intro_title": "Qanday yordam bera olamiz?", + "intro_body": "Nima ishlamadi yoki nimani ko‘rishni xohlashingizni yozing. Har bir xabarni o‘qiymiz va odatda bir kun ichida javob beramiz.", + "new_request": "Yangi murojaat", + "my_requests": "Murojaatlaringiz", + "empty": "Siz hali bizga yozmagansiz.", + "new_answer": "Yangi javob", + "team": "Sozo Support", + "category_title": "Murojaat nima haqida?", + "cat_playback": "Video ijrosi", + "cat_account": "Hisob va kirish", + "cat_content": "Manbalar va kontent", + "cat_bug": "Nimadir ishlamayapti", + "cat_idea": "G‘oya yoki taklif", + "cat_other": "Boshqa", + "message_label": "Xabaringiz", + "message_hint": "Nima bo‘ldi? Nima qildingiz, nimani kutgandingiz va o‘rniga nimani ko‘rdingiz?", + "contact_label": "Aloqa (ixtiyoriy)", + "contact_hint": "Email yoki Telegram username", + "contact_note": "Siz tizimga kirmagansiz — javobimiz shu yerda, «Yordam va Support» bo‘limida chiqadi. U yerda ham javob olishni xohlasangiz, aloqa qoldiring.", + "diag_title": "Diagnostika ma’lumotini qo‘shish", + "diag_body": "Ilova versiyasi, tizim, tarmoq turi va player logi — parollar, tokenlar va cookie’lar olib tashlangan holda. Sababni tezroq topishga yordam beradi.", + "diag_show": "Nima yuborilishini ko‘rish", + "diag_log_lines": "Player logi: {} qator", + "send": "Yuborish", + "sent": "Yuborildi — javobni shu yerda beramiz.", + "status_open": "Javob kutilmoqda", + "status_answered": "Javob berilgan", + "status_closed": "Yopilgan", + "reply_hint": "Xabar yozing…", + "close": "Murojaatni yopish", + "close_confirm_title": "Murojaat yopilsinmi?", + "close_confirm_body": "Istalgan vaqtda yana yozishingiz mumkin — bu uni qayta ochadi.", + "waiting_note": "Xabaringizni oldik, javobni shu yerda beramiz. Tizimga kirgan bo‘lsangiz, bildirishnoma ham keladi.", + "closed_note": "Bu murojaat yopilgan. Yana yozsangiz, u qayta ochiladi.", + "contact_support": "Support’ga yozish", + "error_generic": "Nimadir xato ketdi. Qaytadan urinib ko‘ring.", + "error_too_many": "Hozir so‘rovlar juda ko‘p — biroz kuting.", + "error_short": "Iltimos, yana bir necha so‘z yozing.", + "error_thread_full": "Bu yozishma juda uzun — yangi murojaat oching.", + "error_not_found": "Bu murojaat endi mavjud emas." } } diff --git a/assets/translations/yue.json b/assets/translations/yue.json index ccbf1820..ac264f84 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -733,7 +733,6 @@ "date_format": "日期格式", "date_format_desc": "唔揀嘅話由語言決定。", "date_format_auto": "跟語言", - "section_content": "內容", "adult_content": "18+ 內容", "adult_content_desc": "喺所有地方顯示成人作品同來源 — AniList、TMDB、CloudStream、Aniyomi、漫畫同 torrent。", "adult_confirm_title": "你係咪 18 歲或以上?", @@ -2953,5 +2952,48 @@ "streak_done": "今日搞掂", "streak_tonight": "今晚會斷", "streak_next": "再{left}日攞{target}日徽章" + }, + "support": { + "entry_subtitle": "報告問題或者提出建議", + "intro_title": "有咩可以幫到你?", + "intro_body": "話我哋知邊度出咗問題,或者你想見到啲咩。每條訊息我哋都會睇,通常一日內回覆。", + "new_request": "新查詢", + "my_requests": "你嘅查詢", + "empty": "你仲未搵過我哋。", + "new_answer": "新回覆", + "team": "Sozo 支援", + "category_title": "關於咩嘢?", + "cat_playback": "影片播放", + "cat_account": "帳戶同登入", + "cat_content": "片源同內容", + "cat_bug": "有嘢唔work", + "cat_idea": "意見或者建議", + "cat_other": "其他", + "message_label": "你嘅訊息", + "message_hint": "發生咩事?你做咗咩、預期會點,結果見到啲咩?", + "contact_label": "聯絡方法(可選)", + "contact_hint": "電郵或者 Telegram 用戶名", + "contact_note": "你未登入 —— 我哋嘅回覆會喺呢度「幫助同支援」度出現。如果想喺其他地方都收到,可以留低聯絡方法。", + "diag_title": "附上診斷資料", + "diag_body": "App 版本、系統、網絡類型同播放器紀錄,已經刪除密碼、token 同 cookie。可以幫我哋快啲搵到原因。", + "diag_show": "睇下會傳送咩", + "diag_log_lines": "播放器紀錄:{} 行", + "send": "傳送", + "sent": "已傳送 —— 我哋會喺度回覆。", + "status_open": "等緊回覆", + "status_answered": "已回覆", + "status_closed": "已關閉", + "reply_hint": "寫訊息…", + "close": "關閉查詢", + "close_confirm_title": "要關閉呢個查詢?", + "close_confirm_body": "你隨時可以再寫 —— 咁樣會重新開返。", + "waiting_note": "我哋收到你嘅訊息,會喺度回覆。如果你已經登入,仲會收到通知。", + "closed_note": "呢個查詢已經關閉。再寫會重新開返。", + "contact_support": "搵支援", + "error_generic": "出咗啲問題,請再試一次。", + "error_too_many": "而家太多要求 —— 請等陣。", + "error_short": "請再寫多幾個字。", + "error_thread_full": "呢段對話太長 —— 請開過新查詢。", + "error_not_found": "呢個查詢已經唔存在。" } } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 0fb49ca4..153c900f 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -137,6 +137,7 @@ import 'package:soplay/features/extensions/data/mangayomi_runtime.dart'; import 'package:soplay/features/reports/data/datasources/reports_data_source.dart'; import 'package:soplay/features/reports/data/repositories/reports_repository_impl.dart'; import 'package:soplay/features/reports/domain/repositories/reports_repository.dart'; +import 'package:soplay/features/support/data/support_repository.dart'; import 'package:soplay/features/detail/data/datasources/detail_data_source.dart'; import 'package:soplay/features/detail/data/repositories/detail_repository_impl.dart'; import 'package:soplay/features/detail/domain/repositories/detail_repository.dart'; @@ -785,6 +786,12 @@ Future configureDependencies() async { getIt.registerSingleton( ReportsRepositoryImpl(getIt()), ); + getIt.registerLazySingleton( + () => SupportRepository( + dio: getIt(), + deviceKey: () => getIt().supportDeviceKey, + ), + ); getIt.registerSingleton( AppUpdaterDataSource(dio: getIt()), ); diff --git a/lib/core/diagnostics/player_log.dart b/lib/core/diagnostics/player_log.dart index c6a36559..be0e62da 100644 --- a/lib/core/diagnostics/player_log.dart +++ b/lib/core/diagnostics/player_log.dart @@ -138,9 +138,32 @@ class PlayerLog { String stamp(DateTime t) => '${_two(t.hour)}:${_two(t.minute)}:${_two(t.second)}.${_three(t.millisecond)}'; + /// The context and the newest [maxLines] lines, for a support ticket. + /// + /// Everything here was redacted as it was logged (see [redact]); the ticket's + /// other fields already say which app and device, so no header is repeated. + String formatForSupport({int maxLines = 400}) { + final b = StringBuffer(); + if (_context.isNotEmpty) { + b.writeln('--- context ---'); + _context.forEach((k, v) => b.writeln('$k: $v')); + } + final from = _lines.length > maxLines ? _lines.length - maxLines : 0; + b.writeln('--- log (${_lines.length - from} of ${_lines.length}) ---'); + for (final l in _lines.skip(from)) { + final tag = switch (l.level) { + LogLevel.error => 'E', + LogLevel.warn => 'W', + LogLevel.info => 'I', + }; + b.writeln('${stamp(l.time)} $tag ${l.message}'); + } + return b.toString(); + } + String formatForShare() { final b = StringBuffer() - ..writeln('Soplay player logs') + ..writeln('Sozo player logs') ..writeln('app: ${_appVersion.isEmpty ? 'unknown' : _appVersion}') ..writeln('device: ${_device.isEmpty ? 'unknown' : _device}') ..writeln('captured: ${DateTime.now().toIso8601String()}'); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index a075dff4..cf566077 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -94,6 +94,10 @@ import 'package:soplay/features/notifications/presentation/pages/notifications_p import 'package:soplay/features/private_list/presentation/pages/private_list_page.dart'; import 'package:soplay/features/social/presentation/pages/friends_page.dart'; import 'package:soplay/features/social/presentation/pages/social_privacy_page.dart'; +import 'package:soplay/features/support/data/support_models.dart'; +import 'package:soplay/features/support/presentation/pages/support_new_page.dart'; +import 'package:soplay/features/support/presentation/pages/support_page.dart'; +import 'package:soplay/features/support/presentation/pages/support_ticket_page.dart'; import 'package:soplay/features/social/presentation/pages/user_profile_page.dart'; import 'package:soplay/features/social/presentation/pages/user_search_page.dart'; import 'package:soplay/features/splash/presentation/pages/splash_page.dart'; @@ -475,6 +479,21 @@ class AppRouter { ); }, ), + // Help & Support. `new` before `:id`, which would otherwise take it. + GoRoute(path: '/support', builder: (context, state) => const SupportPage()), + GoRoute( + path: '/support/new', + builder: (context, state) => SupportNewPage( + args: state.extra is SupportRequestArgs + ? state.extra as SupportRequestArgs + : null, + ), + ), + GoRoute( + path: '/support/:id', + builder: (context, state) => + SupportTicketPage(id: state.pathParameters['id'] ?? ''), + ), GoRoute( path: '/link-tv', builder: (context, state) => diff --git a/lib/core/storage/hive_service.dart b/lib/core/storage/hive_service.dart index 632b24ac..443c85cd 100644 --- a/lib/core/storage/hive_service.dart +++ b/lib/core/storage/hive_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../constants/app_constants.dart'; @@ -1394,6 +1395,25 @@ class HiveService { true; } + static const String _supportKey = 'support_device_key'; + + /// The random key a guest's support tickets belong to (features/support). + /// + /// Made once per install from a secure random source and kept on the device; + /// the server stores only its hash. It goes to the support endpoints and + /// nowhere else — whoever holds it can read those tickets, which is exactly + /// what lets a viewer without an account read the answer to their question. + String get supportDeviceKey { + final saved = _settingsBox.get(_supportKey); + if (saved is String && saved.length >= 32) return saved; + final random = Random.secure(); + final key = base64Url + .encode(List.generate(32, (_) => random.nextInt(256))) + .replaceAll('=', ''); + _settingsBox.put(_supportKey, key); + return key; + } + Future setSubtitleAutoTranslate(bool value) async { await _settingsBox.put(AppConstants.subtitleAutoTranslateKey, value); } diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index 247b9596..dedc7a4b 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -365,6 +365,15 @@ extension _PlayerControls on _PlayerPageState { /// out rather than drawing a skeleton that never fills: HLS on iOS, which /// AVAssetImageGenerator cannot read, and DASH anywhere. Downloads are /// included: a local file is the cheapest preview there is. + /// "Title · Episode 5", for a support request about what is playing. + String get _supportContentLabel { + final eps = widget.args.episodes; + if (_episodeIndex >= 0 && _episodeIndex < eps.length) { + return '${widget.args.title} · ${eps[_episodeIndex].label}'; + } + return widget.args.title; + } + bool get _canGeneratePreview => FramePreviewService.isSupported && _videoUrl != null && @@ -516,6 +525,22 @@ extension _PlayerControls on _PlayerPageState { ), ], const SizedBox(height: 10), + // Straight to a playback request, with this title, source and + // the player log already attached (the viewer can remove it). + TextButton.icon( + onPressed: () => context.push( + '/support/new', + extra: SupportRequestArgs( + category: SupportCategory.playback, + provider: widget.args.provider, + content: _supportContentLabel, + screen: 'player', + ), + ), + style: TextButton.styleFrom(foregroundColor: Colors.white60), + icon: const Icon(Icons.support_agent_rounded, size: 18), + label: Text('support.contact_support'.tr()), + ), TextButton.icon( onPressed: () => LogViewerSheet.show(context), style: TextButton.styleFrom(foregroundColor: Colors.white60), diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index d7b62893..cc868019 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -6,6 +6,7 @@ import 'package:soplay/core/player/hls_variants.dart'; import 'package:soplay/core/player/quality_preference.dart'; import 'package:soplay/core/player/stream_cloudflare.dart'; import 'package:soplay/core/player/url_signer.dart'; +import 'package:soplay/features/support/data/support_models.dart'; import 'package:soplay/core/analytics/analytics.dart'; import 'package:soplay/core/player/color_profile.dart'; import 'package:soplay/core/player/shader_presets.dart'; diff --git a/lib/features/notifications/presentation/notification_routing.dart b/lib/features/notifications/presentation/notification_routing.dart index 23b3d9e6..464aff93 100644 --- a/lib/features/notifications/presentation/notification_routing.dart +++ b/lib/features/notifications/presentation/notification_routing.dart @@ -109,6 +109,9 @@ NotificationRoute resolveNotificationRoute( ? '/friends?tab=friends' : '/u/${Uri.encodeComponent(username)}', ); + case 'support_reply': + final ticket = data['ticketId']?.toString() ?? ''; + return _push(ticket.isEmpty ? '/support' : '/support/$ticket'); case 'streak_risk': return (action: NotificationAction.streakTab, location: '/main', extra: null); case 'system_unban': diff --git a/lib/features/profile/presentation/pages/profile_page.hub.dart b/lib/features/profile/presentation/pages/profile_page.hub.dart index c9cf568f..afa5680c 100644 --- a/lib/features/profile/presentation/pages/profile_page.hub.dart +++ b/lib/features/profile/presentation/pages/profile_page.hub.dart @@ -397,6 +397,13 @@ class _HubApp extends StatelessWidget { onTap: () => context.push('/settings'), ), const SettingsDivider(), + SettingsNavTile( + icon: Icons.support_agent_rounded, + title: 'profile.help'.tr(), + subtitle: 'support.entry_subtitle'.tr(), + onTap: () => context.push('/support'), + ), + const SettingsDivider(), FutureBuilder( future: _packageInfo, builder: (context, snap) => SettingsNavTile( diff --git a/lib/features/support/data/support_diagnostics.dart b/lib/features/support/data/support_diagnostics.dart new file mode 100644 index 00000000..ca467452 --- /dev/null +++ b/lib/features/support/data/support_diagnostics.dart @@ -0,0 +1,83 @@ +import 'dart:io' show Platform; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:soplay/core/diagnostics/player_log.dart'; +import 'package:soplay/core/player/player_engine.dart'; +import 'package:soplay/features/support/data/support_models.dart'; + +/// What a support ticket may carry about the device, when the viewer lets it. +/// +/// A fixed list of named fields and the player log, and nothing else: no +/// account, no email, no tokens, no device identifiers. The log is redacted as +/// it is written (see [PlayerLog.redact]) and the server redacts it again +/// before storing it. The viewer sees every field before sending. +class SupportDiagnostics { + const SupportDiagnostics._(); + + /// The newest end of the player log: the failure is almost always there, + /// and 400 lines stay well inside the server's 64 KB. + static const int logLines = 400; + + static Future> collect({ + required String language, + SupportRequestArgs? args, + bool includeLog = true, + }) async { + final out = {}; + void put(String key, String? value) { + final v = value?.trim(); + if (v != null && v.isNotEmpty) out[key] = v; + } + + try { + final info = await PackageInfo.fromPlatform(); + put('app', info.version); + put('build', info.buildNumber); + } catch (_) {} + put('platform', Platform.operatingSystem); + put('os', Platform.operatingSystemVersion); + put('locale', Platform.localeName); + put('language', language); + put('timezone', _utcOffset(DateTime.now().timeZoneOffset)); + try { + put('engine', switch (resolvePlayerEngine()) { + PlayerEngine.native => 'exoplayer', + PlayerEngine.mediaKit => 'mpv', + PlayerEngine.external => 'external', + }); + } catch (_) {} + put('network', await _network()); + put('provider', args?.provider); + put('content', args?.content); + put('screen', args?.screen); + if (includeLog) { + final log = PlayerLog.instance; + if (log.lines.isNotEmpty) { + put('log', log.formatForSupport(maxLines: logLines)); + } + } + return out; + } + + static String _utcOffset(Duration offset) { + final sign = offset.isNegative ? '-' : '+'; + final m = offset.inMinutes.abs(); + return 'UTC$sign${(m ~/ 60).toString().padLeft(2, '0')}:' + '${(m % 60).toString().padLeft(2, '0')}'; + } + + static Future _network() async { + try { + final kinds = await Connectivity().checkConnectivity(); + if (kinds.contains(ConnectivityResult.wifi)) return 'wifi'; + if (kinds.contains(ConnectivityResult.ethernet)) return 'ethernet'; + if (kinds.contains(ConnectivityResult.mobile)) return 'mobile'; + if (kinds.contains(ConnectivityResult.vpn)) return 'vpn'; + if (kinds.contains(ConnectivityResult.none)) return 'none'; + return kinds.isEmpty ? null : kinds.first.name; + } catch (_) { + return null; + } + } +} diff --git a/lib/features/support/data/support_models.dart b/lib/features/support/data/support_models.dart new file mode 100644 index 00000000..13f643f5 --- /dev/null +++ b/lib/features/support/data/support_models.dart @@ -0,0 +1,123 @@ +/// What a viewer's request to support is about. The server keeps the same +/// list (models/SupportTicket.js); a value it does not know is refused. +enum SupportCategory { playback, account, content, bug, idea, other } + +SupportCategory supportCategoryOf(String? name) => SupportCategory.values + .firstWhere((c) => c.name == name, orElse: () => SupportCategory.other); + +enum SupportStatus { open, answered, closed } + +SupportStatus supportStatusOf(String? name) => SupportStatus.values.firstWhere( + (s) => s.name == name, + orElse: () => SupportStatus.open, +); + +class SupportMessage { + const SupportMessage({ + required this.id, + required this.fromSupport, + required this.body, + required this.at, + }); + + final String id; + final bool fromSupport; + final String body; + final DateTime at; + + factory SupportMessage.fromJson(Map json) => SupportMessage( + id: json['id']?.toString() ?? '', + fromSupport: json['from'] == 'admin', + body: json['body']?.toString() ?? '', + at: + DateTime.tryParse(json['at']?.toString() ?? '')?.toLocal() ?? + DateTime.now(), + ); +} + +class SupportTicket { + const SupportTicket({ + required this.id, + required this.category, + required this.status, + required this.unread, + required this.hasDiagnostics, + required this.preview, + required this.lastFromSupport, + required this.lastMessageAt, + required this.createdAt, + this.messages = const [], + }); + + final String id; + final SupportCategory category; + final SupportStatus status; + + /// An answer from support this viewer has not opened yet. + final bool unread; + final bool hasDiagnostics; + final String preview; + final bool lastFromSupport; + final DateTime lastMessageAt; + final DateTime createdAt; + + /// Empty in a list; the thread arrives with the ticket itself. + final List messages; + + factory SupportTicket.fromJson(Map json) { + DateTime date(Object? v) => + DateTime.tryParse(v?.toString() ?? '')?.toLocal() ?? DateTime.now(); + final raw = json['messages']; + return SupportTicket( + id: json['id']?.toString() ?? '', + category: supportCategoryOf(json['category']?.toString()), + status: supportStatusOf(json['status']?.toString()), + unread: json['userUnread'] == true, + hasDiagnostics: json['hasDiagnostics'] == true, + preview: json['preview']?.toString() ?? '', + lastFromSupport: json['lastFrom'] == 'admin', + lastMessageAt: date(json['lastMessageAt']), + createdAt: date(json['createdAt']), + messages: raw is List + ? raw + .whereType() + .map((m) => SupportMessage.fromJson(m.cast())) + .toList() + : const [], + ); + } +} + +class SupportInbox { + const SupportInbox({required this.tickets, required this.unread}); + + final List tickets; + final int unread; +} + +/// Where a request was started from, so the form opens on the right category +/// and the diagnostics say what was on screen. +class SupportRequestArgs { + const SupportRequestArgs({ + this.category, + this.provider, + this.content, + this.screen, + }); + + final SupportCategory? category; + final String? provider; + final String? content; + final String? screen; +} + +/// A refusal worth showing as it is: the server writes these for people. +class SupportException implements Exception { + const SupportException(this.message, {this.code}); + + final String message; + final String? code; + + @override + String toString() => message; +} diff --git a/lib/features/support/data/support_repository.dart b/lib/features/support/data/support_repository.dart new file mode 100644 index 00000000..34e8df94 --- /dev/null +++ b/lib/features/support/data/support_repository.dart @@ -0,0 +1,108 @@ +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:soplay/features/support/data/support_models.dart'; + +/// Tickets to and from support (backend: /api/support). +/// +/// Every call carries this install's support key alongside whatever account +/// the app is signed in to. A guest's tickets belong to the key, and a guest +/// who signs in later still sees what they wrote before. +class SupportRepository { + /// [deviceKey] is HiveService.supportDeviceKey in the app. + SupportRepository({required Dio dio, required String Function() deviceKey}) + : _dio = dio, + _deviceKey = deviceKey; + + final Dio _dio; + final String Function() _deviceKey; + + Options get _options => Options(headers: {'X-Support-Key': _deviceKey()}); + + Future list({int page = 1}) => _guard(() async { + final res = await _dio.get>( + '/support/tickets', + queryParameters: {'page': page, 'limit': 30}, + options: _options, + ); + final data = res.data ?? const {}; + final items = (data['items'] as List? ?? const []) + .whereType() + .map((m) => SupportTicket.fromJson(m.cast())) + .toList(); + return SupportInbox( + tickets: items, + unread: (data['unread'] as num?)?.toInt() ?? 0, + ); + }); + + Future get(String id) => _guard(() async { + final res = await _dio.get>( + '/support/tickets/$id', + options: _options, + ); + return _ticket(res.data); + }); + + Future create({ + required SupportCategory category, + required String message, + String? contact, + Map? diagnostics, + }) => _guard(() async { + final res = await _dio.post>( + '/support/tickets', + data: { + 'category': category.name, + 'message': message, + if (contact != null && contact.trim().isNotEmpty) + 'contact': contact.trim(), + if (diagnostics != null && diagnostics.isNotEmpty) + 'diagnostics': diagnostics, + }, + options: _options, + ); + return _ticket(res.data); + }); + + Future reply(String id, String message) => _guard(() async { + final res = await _dio.post>( + '/support/tickets/$id/messages', + data: {'message': message}, + options: _options, + ); + return _ticket(res.data); + }); + + Future close(String id) => _guard(() async { + final res = await _dio.post>( + '/support/tickets/$id/close', + options: _options, + ); + return _ticket(res.data); + }); + + SupportTicket _ticket(Map? data) { + final raw = data?['ticket']; + if (raw is! Map) throw SupportException('support.error_generic'.tr()); + return SupportTicket.fromJson(raw.cast()); + } + + /// The refusal in the viewer's language. The server's own messages are + /// Uzbek, written for the admin panel, so only its codes are read here. + Future _guard(Future Function() call) async { + try { + return await call(); + } on DioException catch (e) { + final body = e.response?.data; + final code = body is Map ? body['code']?.toString() : null; + final key = switch ((e.response?.statusCode, code)) { + (429, _) => 'support.error_too_many', + (_, 'SUPPORT_MESSAGE') => 'support.error_short', + (_, 'SUPPORT_THREAD_FULL') => 'support.error_thread_full', + (_, 'SUPPORT_NOT_FOUND') => 'support.error_not_found', + _ => 'support.error_generic', + }; + throw SupportException(key.tr(), code: code); + } + } +} diff --git a/lib/features/support/presentation/pages/support_new_page.dart b/lib/features/support/presentation/pages/support_new_page.dart new file mode 100644 index 00000000..fa77af75 --- /dev/null +++ b/lib/features/support/presentation/pages/support_new_page.dart @@ -0,0 +1,365 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/widgets/app_buttons.dart'; +import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; +import 'package:soplay/features/support/data/support_diagnostics.dart'; +import 'package:soplay/features/support/data/support_models.dart'; +import 'package:soplay/features/support/data/support_repository.dart'; +import 'package:soplay/features/support/presentation/support_style.dart'; + +/// A new request to support. +/// +/// Diagnostics are the viewer's choice — on by default, one switch to turn +/// off — and shown in full before anything is sent. +class SupportNewPage extends StatefulWidget { + const SupportNewPage({super.key, this.args}); + + final SupportRequestArgs? args; + + @override + State createState() => _SupportNewPageState(); +} + +class _SupportNewPageState extends State { + final SupportRepository _repo = getIt(); + final HiveService _hive = getIt(); + final TextEditingController _message = TextEditingController(); + final TextEditingController _contact = TextEditingController(); + + late SupportCategory? _category = widget.args?.category; + bool _attach = true; + bool _showDiagnostics = false; + bool _sending = false; + String? _error; + Map? _diagnostics; + Future? _collecting; + + bool get _guest => !_hive.isLoggedIn; + bool get _canSend => + !_sending && _category != null && _message.text.trim().length >= 3; + + @override + void initState() { + super.initState(); + _message.addListener(() => setState(() {})); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Once: the locale it reads needs the context, which initState lacks. + _collecting ??= _collect(); + } + + Future _collect() async { + final d = await SupportDiagnostics.collect( + language: context.locale.languageCode, + args: widget.args, + ); + if (mounted) setState(() => _diagnostics = d); + } + + @override + void dispose() { + _message.dispose(); + _contact.dispose(); + super.dispose(); + } + + Future _send() async { + if (!_canSend) return; + FocusScope.of(context).unfocus(); + setState(() { + _sending = true; + _error = null; + }); + // Collected when the page opened; a send in the first instant waits for it. + if (_attach) await _collecting; + if (!mounted) return; + try { + final ticket = await _repo.create( + category: _category!, + message: _message.text.trim(), + contact: _guest ? _contact.text : null, + diagnostics: _attach ? _diagnostics : null, + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('support.sent'.tr()))); + context.pushReplacement('/support/${ticket.id}'); + } on SupportException catch (e) { + if (mounted) { + setState(() { + _sending = false; + _error = e.message; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return SettingsPageScaffold( + title: 'support.new_request'.tr(), + children: [ + SettingsLabel('support.category_title'.tr()), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final c in SupportCategory.values) + ChoiceChip( + avatar: Icon( + c.icon, + size: 18, + color: _category == c + ? AppColors.onPrimary + : AppColors.textSecondary, + ), + label: Text(c.label), + selected: _category == c, + showCheckmark: false, + onSelected: _sending + ? null + : (_) => setState(() => _category = c), + labelStyle: TextStyle( + color: _category == c + ? AppColors.onPrimary + : AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + selectedColor: AppColors.primary, + backgroundColor: AppColors.surface, + side: BorderSide(color: Colors.white.withValues(alpha: 0.06)), + ), + ], + ), + const SizedBox(height: 20), + SettingsLabel('support.message_label'.tr()), + _Field( + controller: _message, + hint: 'support.message_hint'.tr(), + minLines: 5, + maxLines: 12, + maxLength: 2000, + enabled: !_sending, + ), + if (_guest) ...[ + const SizedBox(height: 16), + SettingsLabel('support.contact_label'.tr()), + _Field( + controller: _contact, + hint: 'support.contact_hint'.tr(), + maxLength: 120, + enabled: !_sending, + ), + const SizedBox(height: 6), + Text( + 'support.contact_note'.tr(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12, + height: 1.4, + ), + ), + ], + const SizedBox(height: 20), + _DiagnosticsCard( + attach: _attach, + onAttach: _sending ? null : (v) => setState(() => _attach = v), + expanded: _showDiagnostics, + onToggle: () => setState(() => _showDiagnostics = !_showDiagnostics), + diagnostics: _diagnostics ?? const {}, + ), + if (_error != null) ...[ + const SizedBox(height: 14), + Text( + _error!, + style: const TextStyle(color: AppColors.errorLight, fontSize: 13), + ), + ], + const SizedBox(height: 20), + AppPrimaryButton( + label: 'support.send'.tr(), + icon: Icons.send_rounded, + loading: _sending, + onPressed: _canSend ? _send : null, + ), + ], + ); + } +} + +class _Field extends StatelessWidget { + const _Field({ + required this.controller, + required this.hint, + this.minLines = 1, + this.maxLines = 1, + this.maxLength, + this.enabled = true, + }); + + final TextEditingController controller; + final String hint; + final int minLines; + final int maxLines; + final int? maxLength; + final bool enabled; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + enabled: enabled, + minLines: minLines, + maxLines: maxLines, + maxLength: maxLength, + textCapitalization: TextCapitalization.sentences, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14.5), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.textHint, fontSize: 14), + filled: true, + fillColor: AppColors.surface, + counterStyle: const TextStyle(color: AppColors.textHint, fontSize: 11), + contentPadding: const EdgeInsets.all(14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.06)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.06)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: AppColors.primary, width: 1.2), + ), + ), + ); + } +} + +class _DiagnosticsCard extends StatelessWidget { + const _DiagnosticsCard({ + required this.attach, + required this.onAttach, + required this.expanded, + required this.onToggle, + required this.diagnostics, + }); + + final bool attach; + final ValueChanged? onAttach; + final bool expanded; + final VoidCallback onToggle; + final Map diagnostics; + + @override + Widget build(BuildContext context) { + final fields = diagnostics.entries.where((e) => e.key != 'log').toList(); + final log = diagnostics['log']; + final logLines = log == null ? 0 : '\n'.allMatches(log).length; + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.white.withValues(alpha: 0.05), + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile.adaptive( + value: attach, + onChanged: onAttach, + activeTrackColor: AppColors.primary, + contentPadding: const EdgeInsets.fromLTRB(14, 4, 8, 0), + title: Text( + 'support.diag_title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'support.diag_body'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + height: 1.4, + ), + ), + ), + ), + if (attach) + TextButton.icon( + onPressed: onToggle, + icon: Icon( + expanded + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 18, + ), + label: Text( + expanded ? 'general.hide'.tr() : 'support.diag_show'.tr(), + ), + ), + if (attach && expanded) + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final e in fields) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: '${e.key}: ', + style: const TextStyle(color: AppColors.textHint), + ), + TextSpan( + text: e.value, + style: const TextStyle( + color: AppColors.textPrimary, + ), + ), + ], + ), + style: const TextStyle(fontSize: 12.5, height: 1.35), + ), + ), + if (log != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'support.diag_log_lines'.tr(args: ['$logLines']), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/support/presentation/pages/support_page.dart b/lib/features/support/presentation/pages/support_page.dart new file mode 100644 index 00000000..7807a5c4 --- /dev/null +++ b/lib/features/support/presentation/pages/support_page.dart @@ -0,0 +1,315 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/widgets/app_buttons.dart'; +import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; +import 'package:soplay/features/support/data/support_models.dart'; +import 'package:soplay/features/support/data/support_repository.dart'; +import 'package:soplay/features/support/presentation/support_style.dart'; + +/// Help & Support: write to the team, and read what they answered. +class SupportPage extends StatefulWidget { + const SupportPage({super.key}); + + @override + State createState() => _SupportPageState(); +} + +class _SupportPageState extends State { + final SupportRepository _repo = getIt(); + SupportInbox? _inbox; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final inbox = await _repo.list(); + if (mounted) { + setState(() { + _inbox = inbox; + _error = null; + }); + } + } on SupportException catch (e) { + if (mounted) setState(() => _error = e.message); + } + } + + Future _open(String location) async { + await context.push(location); + // Back from a thread: its unread mark, its status and its last message + // may all have changed. + if (mounted) _load(); + } + + @override + Widget build(BuildContext context) { + final inbox = _inbox; + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + title: Text( + 'profile.help'.tr(), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + ), + body: RefreshIndicator( + color: AppColors.primary, + onRefresh: _load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + MediaQuery.paddingOf(context).bottom + 24, + ), + children: [ + _Intro(onNew: () => _open('/support/new')), + const SizedBox(height: 20), + SettingsLabel('support.my_requests'.tr()), + if (_error != null && inbox == null) + _Message( + text: _error!, + action: TextButton( + onPressed: () { + setState(() => _error = null); + _load(); + }, + child: Text('general.retry'.tr()), + ), + ) + else if (inbox == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator()), + ) + else if (inbox.tickets.isEmpty) + _Message(text: 'support.empty'.tr()) + else + SettingsCard( + children: [ + for (var i = 0; i < inbox.tickets.length; i++) ...[ + if (i > 0) const SettingsDivider(), + _TicketTile( + ticket: inbox.tickets[i], + onTap: () => _open('/support/${inbox.tickets[i].id}'), + ), + ], + ], + ), + ], + ), + ), + ); + } +} + +class _Intro extends StatelessWidget { + const _Intro({required this.onNew}); + + final VoidCallback onNew; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(18, 18, 18, 16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withValues(alpha: 0.05), + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.support_agent_rounded, + color: AppColors.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'support.intro_title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'support.intro_body'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13.5, + height: 1.4, + ), + ), + const SizedBox(height: 16), + AppPrimaryButton( + label: 'support.new_request'.tr(), + icon: Icons.edit_outlined, + onPressed: onNew, + ), + ], + ), + ); + } +} + +class _TicketTile extends StatelessWidget { + const _TicketTile({required this.ticket, required this.onTap}); + + final SupportTicket ticket; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final preview = ticket.lastFromSupport + ? '${'support.team'.tr()}: ${ticket.preview}' + : ticket.preview; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + ticket.category.icon, + color: AppColors.textSecondary, + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + ticket.category.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + fontWeight: ticket.unread + ? FontWeight.w700 + : FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + supportTime(ticket.lastMessageAt), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + preview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: ticket.unread + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: 13, + height: 1.35, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + SupportStatusChip(status: ticket.status), + if (ticket.unread) ...[ + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + 'support.new_answer'.tr(), + style: TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _Message extends StatelessWidget { + const _Message({required this.text, this.action}); + + final String text; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 8), + child: Column( + children: [ + Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + ), + ), + ?action, + ], + ), + ); + } +} diff --git a/lib/features/support/presentation/pages/support_ticket_page.dart b/lib/features/support/presentation/pages/support_ticket_page.dart new file mode 100644 index 00000000..fc693333 --- /dev/null +++ b/lib/features/support/presentation/pages/support_ticket_page.dart @@ -0,0 +1,374 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/support/data/support_models.dart'; +import 'package:soplay/features/support/data/support_repository.dart'; +import 'package:soplay/features/support/presentation/support_style.dart'; + +/// One conversation with support. Opening it marks the answer read. +class SupportTicketPage extends StatefulWidget { + const SupportTicketPage({super.key, required this.id}); + + final String id; + + @override + State createState() => _SupportTicketPageState(); +} + +class _SupportTicketPageState extends State { + final SupportRepository _repo = getIt(); + final TextEditingController _reply = TextEditingController(); + final ScrollController _scroll = ScrollController(); + + SupportTicket? _ticket; + String? _error; + bool _sending = false; + + @override + void initState() { + super.initState(); + _reply.addListener(() => setState(() {})); + _load(); + } + + @override + void dispose() { + _reply.dispose(); + _scroll.dispose(); + super.dispose(); + } + + Future _load() async { + try { + final t = await _repo.get(widget.id); + if (!mounted) return; + setState(() { + _ticket = t; + _error = null; + }); + _toBottom(); + } on SupportException catch (e) { + if (mounted) setState(() => _error = e.message); + } + } + + void _toBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + }); + } + + Future _send() async { + final text = _reply.text.trim(); + if (text.length < 3 || _sending) return; + setState(() => _sending = true); + try { + final t = await _repo.reply(widget.id, text); + if (!mounted) return; + _reply.clear(); + setState(() { + _ticket = t; + _sending = false; + }); + _toBottom(); + } on SupportException catch (e) { + if (!mounted) return; + setState(() => _sending = false); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.message))); + } + } + + Future _close() async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppColors.surface, + title: Text('support.close_confirm_title'.tr()), + content: Text('support.close_confirm_body'.tr()), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text('general.cancel'.tr()), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text('support.close'.tr()), + ), + ], + ), + ); + if (ok != true || !mounted) return; + try { + final t = await _repo.close(widget.id); + if (mounted) setState(() => _ticket = t); + } on SupportException catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.message))); + } + } + } + + @override + Widget build(BuildContext context) { + final t = _ticket; + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + title: Text( + t?.category.label ?? 'profile.help'.tr(), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + actions: [ + if (t != null && t.status != SupportStatus.closed) + IconButton( + tooltip: 'support.close'.tr(), + icon: const Icon(Icons.check_circle_outline_rounded), + onPressed: _close, + ), + ], + ), + body: t == null + ? Center( + child: _error == null + ? const CircularProgressIndicator() + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _error!, + style: const TextStyle( + color: AppColors.textSecondary, + ), + ), + TextButton( + onPressed: () { + setState(() => _error = null); + _load(); + }, + child: Text('general.retry'.tr()), + ), + ], + ), + ) + : Column( + children: [ + Expanded( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: _load, + child: ListView( + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + children: [ + Center(child: SupportStatusChip(status: t.status)), + const SizedBox(height: 14), + for (final m in t.messages) _Bubble(message: m), + if (t.status == SupportStatus.open && + !t.messages.any((m) => m.fromSupport)) + _Note(text: 'support.waiting_note'.tr()), + if (t.status == SupportStatus.closed) + _Note(text: 'support.closed_note'.tr()), + ], + ), + ), + ), + _Composer( + controller: _reply, + sending: _sending, + onSend: _reply.text.trim().length >= 3 ? _send : null, + ), + ], + ), + ); + } +} + +class _Bubble extends StatelessWidget { + const _Bubble({required this.message}); + + final SupportMessage message; + + @override + Widget build(BuildContext context) { + final mine = !message.fromSupport; + final radius = BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(mine ? 16 : 4), + bottomRight: Radius.circular(mine ? 4 : 16), + ); + return Align( + alignment: mine + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.8, + ), + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.fromLTRB(12, 9, 12, 7), + decoration: BoxDecoration( + color: mine + ? AppColors.primary.withValues(alpha: 0.16) + : AppColors.surface, + borderRadius: radius, + border: Border.all( + color: mine + ? AppColors.primary.withValues(alpha: 0.25) + : Colors.white.withValues(alpha: 0.06), + width: 0.6, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!mine) + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + 'support.team'.tr(), + style: TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + SelectableText( + message.body, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + height: 1.4, + ), + ), + const SizedBox(height: 4), + Text( + supportTime(message.at), + style: const TextStyle(color: AppColors.textHint, fontSize: 11), + ), + ], + ), + ), + ), + ); + } +} + +class _Note extends StatelessWidget { + const _Note({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12.5, + height: 1.4, + ), + ), + ); + } +} + +class _Composer extends StatelessWidget { + const _Composer({ + required this.controller, + required this.sending, + required this.onSend, + }); + + final TextEditingController controller; + final bool sending; + final VoidCallback? onSend; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB( + 12, + 8, + 8, + 8 + MediaQuery.paddingOf(context).bottom, + ), + decoration: BoxDecoration( + color: AppColors.background, + border: Border( + top: BorderSide(color: Colors.white.withValues(alpha: 0.06)), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: controller, + enabled: !sending, + minLines: 1, + maxLines: 5, + maxLength: 2000, + textCapitalization: TextCapitalization.sentences, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + ), + decoration: InputDecoration( + hintText: 'support.reply_hint'.tr(), + hintStyle: const TextStyle(color: AppColors.textHint), + counterText: '', + filled: true, + fillColor: AppColors.surface, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 11, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 6), + SizedBox( + width: 44, + height: 44, + child: sending + ? const Padding( + padding: EdgeInsets.all(11), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + onPressed: onSend, + icon: Icon( + Icons.send_rounded, + color: onSend == null + ? AppColors.textHint + : AppColors.primary, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/support/presentation/support_style.dart b/lib/features/support/presentation/support_style.dart new file mode 100644 index 00000000..76d0eb1c --- /dev/null +++ b/lib/features/support/presentation/support_style.dart @@ -0,0 +1,59 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/support/data/support_models.dart'; + +/// Labels and glyphs the support screens share. +extension SupportCategoryUi on SupportCategory { + String get label => 'support.cat_$name'.tr(); + + IconData get icon => switch (this) { + SupportCategory.playback => Icons.play_circle_outline_rounded, + SupportCategory.account => Icons.person_outline_rounded, + SupportCategory.content => Icons.video_library_outlined, + SupportCategory.bug => Icons.bug_report_outlined, + SupportCategory.idea => Icons.lightbulb_outline_rounded, + SupportCategory.other => Icons.help_outline_rounded, + }; +} + +class SupportStatusChip extends StatelessWidget { + const SupportStatusChip({super.key, required this.status}); + + final SupportStatus status; + + @override + Widget build(BuildContext context) { + final color = switch (status) { + SupportStatus.open => AppColors.textSecondary, + SupportStatus.answered => AppColors.success, + SupportStatus.closed => AppColors.textHint, + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'support.status_${status.name}'.tr(), + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +/// "14:05" today, "26.09" this year, "26.09.2025" before. +String supportTime(DateTime at) { + final now = DateTime.now(); + String two(int n) => n.toString().padLeft(2, '0'); + if (at.year == now.year && at.month == now.month && at.day == now.day) { + return '${two(at.hour)}:${two(at.minute)}'; + } + final date = '${two(at.day)}.${two(at.month)}'; + return at.year == now.year ? date : '$date.${at.year}'; +} diff --git a/test/features/notifications/notification_routing_test.dart b/test/features/notifications/notification_routing_test.dart index b29fa56c..2bcb6818 100644 --- a/test/features/notifications/notification_routing_test.dart +++ b/test/features/notifications/notification_routing_test.dart @@ -66,4 +66,17 @@ void main() { expect(isLaunchRoute('/main'), isFalse); expect(isLaunchRoute('/detail'), isFalse); }); + + test('an answer from support opens its conversation', () { + final route = resolveNotificationRoute({ + 'type': 'support_reply', + 'ticketId': '6ab7895d342db7ef3ac3bbe4', + }); + expect(route.action, NotificationAction.push); + expect(route.location, '/support/6ab7895d342db7ef3ac3bbe4'); + expect( + resolveNotificationRoute({'type': 'support_reply'}).location, + '/support', + ); + }); } diff --git a/test/features/support/support_diagnostics_test.dart b/test/features/support/support_diagnostics_test.dart new file mode 100644 index 00000000..7eb13990 --- /dev/null +++ b/test/features/support/support_diagnostics_test.dart @@ -0,0 +1,82 @@ +// What a support ticket may carry about the device, and what it keeps out. +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:soplay/core/diagnostics/player_log.dart'; +import 'package:soplay/features/support/data/support_diagnostics.dart'; +import 'package:soplay/features/support/data/support_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('diagnostics', () { + setUp(() { + PackageInfo.setMockInitialValues( + appName: 'Sozo', + packageName: 'com.soplay.sozo', + version: '3.2.0', + buildNumber: '8', + buildSignature: '', + ); + PlayerLog.instance.clear(); + }); + + test('named fields only, and the log already redacted', () async { + PlayerLog.instance.add('Cookie: cf_clearance=SECRET'); + PlayerLog.instance.add('GET https://cdn/x.m3u8?token=T0K&expires=9'); + final d = await SupportDiagnostics.collect( + language: 'uz', + args: const SupportRequestArgs( + provider: 'anikai', + content: 'Frieren · 5', + screen: 'player', + ), + ); + expect(d['app'], '3.2.0'); + expect(d['build'], '8'); + expect(d['language'], 'uz'); + expect(d['provider'], 'anikai'); + expect(d['screen'], 'player'); + expect(d['timezone'], startsWith('UTC')); + expect( + d.keys.toSet().difference({ + 'app', + 'build', + 'platform', + 'os', + 'locale', + 'language', + 'timezone', + 'engine', + 'network', + 'provider', + 'content', + 'screen', + 'log', + }), + isEmpty, + ); + expect(d['log'], isNot(contains('SECRET'))); + expect(d['log'], isNot(contains('T0K'))); + expect(d['log'], contains('expires=9')); + }); + + test('the viewer can leave the log out', () async { + PlayerLog.instance.add('something happened'); + final d = await SupportDiagnostics.collect( + language: 'en', + includeLog: false, + ); + expect(d.containsKey('log'), isFalse); + }); + + test('a long log keeps its newest lines', () { + for (var i = 0; i < 700; i++) { + PlayerLog.instance.add('line $i'); + } + final text = PlayerLog.instance.formatForSupport(maxLines: 400); + expect(text, contains('(400 of 700)')); + expect(text, contains('line 699')); + expect(text, isNot(contains('line 299\n'))); + }); + }); +} diff --git a/test/features/support/support_repository_test.dart b/test/features/support/support_repository_test.dart new file mode 100644 index 00000000..5df9599a --- /dev/null +++ b/test/features/support/support_repository_test.dart @@ -0,0 +1,148 @@ +// What the app sends to support and what it reads back. Real HTTP against a +// local server, so no test binding (it answers every request with a 400). +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/support/data/support_models.dart'; +import 'package:soplay/features/support/data/support_repository.dart'; + +void main() { + group('SupportRepository', () { + late HttpServer server; + final seen = <({String method, String path, String? key, Object? body})>[]; + var status = 200; + Map answer = {}; + + setUp(() async { + seen.clear(); + status = 200; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((req) async { + final raw = await utf8.decoder.bind(req).join(); + seen.add(( + method: req.method, + path: req.uri.path, + key: req.headers.value('x-support-key'), + body: raw.isEmpty ? null : jsonDecode(raw), + )); + req.response + ..statusCode = status + ..headers.contentType = ContentType.json + ..write(jsonEncode(answer)); + await req.response.close(); + }); + }); + + tearDown(() => server.close(force: true)); + + SupportRepository repo() => SupportRepository( + dio: Dio(BaseOptions(baseUrl: 'http://127.0.0.1:${server.port}/api')), + deviceKey: () => 'k' * 43, + ); + + final ticketJson = { + 'id': 't1', + 'category': 'playback', + 'status': 'answered', + 'userUnread': true, + 'hasDiagnostics': true, + 'preview': 'Try another source', + 'lastFrom': 'admin', + 'lastMessageAt': '2026-09-26T09:00:00Z', + 'createdAt': '2026-09-26T08:00:00Z', + 'messages': [ + { + 'id': 'm1', + 'from': 'user', + 'body': 'It will not play', + 'at': '2026-09-26T08:00:00Z', + }, + { + 'id': 'm2', + 'from': 'admin', + 'body': 'Try another source', + 'at': '2026-09-26T09:00:00Z', + }, + ], + }; + + test( + 'every call carries the device key; a ticket reads back whole', + () async { + answer = {'ticket': ticketJson}; + final t = await repo().create( + category: SupportCategory.playback, + message: 'It will not play', + contact: ' ', + diagnostics: {'app': '3.2.0', 'engine': 'mpv'}, + ); + expect(seen.single.key, 'k' * 43); + expect(seen.single.path, '/api/support/tickets'); + final body = seen.single.body as Map; + expect(body['category'], 'playback'); + expect( + body.containsKey('contact'), + isFalse, + reason: 'a blank contact is not sent', + ); + expect(body['diagnostics'], {'app': '3.2.0', 'engine': 'mpv'}); + + expect(t.status, SupportStatus.answered); + expect(t.unread, isTrue); + expect(t.messages.map((m) => m.fromSupport), [false, true]); + }, + ); + + test('no diagnostics means none are sent', () async { + answer = {'ticket': ticketJson}; + await repo().create( + category: SupportCategory.idea, + message: 'Dark AMOLED theme', + ); + expect((seen.single.body as Map).containsKey('diagnostics'), isFalse); + }); + + test('an inbox counts its unread answers', () async { + answer = { + 'items': [ticketJson], + 'unread': 1, + }; + final inbox = await repo().list(); + expect(inbox.tickets.single.id, 't1'); + expect(inbox.unread, 1); + expect(seen.single.key, 'k' * 43); + }); + + test( + 'refusals are the app\'s own words, never the server\'s Uzbek', + () async { + status = 429; + answer = {'message': "Juda ko'p so'rov"}; + await expectLater( + repo().create(category: SupportCategory.bug, message: 'Crashes'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'support.error_too_many', + ), + ), + ); + status = 400; + answer = {'message': 'Xabar juda qisqa', 'code': 'SUPPORT_MESSAGE'}; + await expectLater( + repo().reply('t1', 'ok'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'SUPPORT_MESSAGE', + ), + ), + ); + }, + ); + }); +} From b55d12b3da837ffe9b4e09968991eb9291df4769 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:33:36 +0500 Subject: [PATCH 3/7] Support: give the diagnostics switch its white thumb Left at its default, the thumb took the track's primary red and the switch read as a red pill with no visible on state; SettingsSwitchTile already sets it white for the same reason. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/features/support/presentation/pages/support_new_page.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/features/support/presentation/pages/support_new_page.dart b/lib/features/support/presentation/pages/support_new_page.dart index fa77af75..b787bd6c 100644 --- a/lib/features/support/presentation/pages/support_new_page.dart +++ b/lib/features/support/presentation/pages/support_new_page.dart @@ -282,6 +282,9 @@ class _DiagnosticsCard extends StatelessWidget { SwitchListTile.adaptive( value: attach, onChanged: onAttach, + // White thumb, as SettingsSwitchTile has it: at its default the + // thumb takes the track's primary and the "on" state vanishes. + activeThumbColor: Colors.white, activeTrackColor: AppColors.primary, contentPadding: const EdgeInsets.fromLTRB(14, 4, 8, 0), title: Text( From 3c300d2fe89d879bb9691cb4179ec8b0d29b17ed Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:56:03 +0500 Subject: [PATCH 4/7] Test: wait for the unfollow to go up instead of pumping a fixed count The queue drains through real storage I/O, so a fixed number of event loop pumps passed locally and failed on a slower CI runner. The test now waits for the outcome itself, with a two-second ceiling. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tracker/follow_sync_service_test.dart | 110 +++++++++++------- 1 file changed, 67 insertions(+), 43 deletions(-) diff --git a/test/features/tracker/follow_sync_service_test.dart b/test/features/tracker/follow_sync_service_test.dart index 2160e83d..921ffdac 100644 --- a/test/features/tracker/follow_sync_service_test.dart +++ b/test/features/tracker/follow_sync_service_test.dart @@ -183,16 +183,19 @@ void main() { expect(sync.pendingCount, 0); }); - test('a 404 means not deployed: local keeps working, nothing is lost', () async { - remote.mode = _Mode.notDeployed; - await follows.follow(_title('a')); - await pumpEventQueue(); - expect(sync.serverLive, isFalse); - expect(follows.list().single.contentUrl, 'a'); - expect(sync.pendingCount, 1); - expect(await sync.fullSync(force: true), isFalse); - expect(follows.list(), hasLength(1)); - }); + test( + 'a 404 means not deployed: local keeps working, nothing is lost', + () async { + remote.mode = _Mode.notDeployed; + await follows.follow(_title('a')); + await pumpEventQueue(); + expect(sync.serverLive, isFalse); + expect(follows.list().single.contentUrl, 'a'); + expect(sync.pendingCount, 1); + expect(await sync.fullSync(force: true), isFalse); + expect(follows.list(), hasLength(1)); + }, + ); test('an unfollow travels as a tombstone in the full sync', () async { remote.mode = _Mode.offline; @@ -207,7 +210,10 @@ void main() { test('the merge keeps device-only fields and never lowers a count', () async { hive.raw = [ - _title('a', count: 12).copyWith(autoDownload: true, autoDownloadFrom: 9).toJson(), + _title( + 'a', + count: 12, + ).copyWith(autoDownload: true, autoDownloadFrom: 9).toJson(), ]; remote.server = [ _title('a', count: 10).copyWith(anilistId: 7), @@ -231,37 +237,52 @@ void main() { expect(remote.calls, contains('sync')); }); - test('an unfollow made while the follow is in flight still goes up', () async { - remote.hold = Completer(); - await follows.follow(_title('a')); - await pumpEventQueue(); - expect(remote.calls, ['add a']); - await follows.unfollow('a'); - await pumpEventQueue(); - remote.hold!.complete(); - remote.hold = null; - await pumpEventQueue(); - expect(remote.calls, ['add a', 'remove a']); - expect(sync.pendingCount, 0); - }); - - test('a profile switch mid-sync keeps the old list out of the new profile', () async { - hive.raw = [_title('mine').toJson()]; - remote.server = [_title('theirs')]; - remote.hold = Completer(); - final first = sync.fullSync(force: true); - await pumpEventQueue(); - ProfileScope.set(namespace: 'kid', remoteId: 'kid'); - remote.hold!.complete(); - remote.hold = null; - remote.server = [_title('mine')]; - expect(await first, isFalse); - expect(hive.raw.map((e) => e['contentUrl']), ['mine']); - await pumpEventQueue(); - // The new profile gets a sync of its own instead of being skipped. - expect(remote.calls.where((c) => c == 'sync'), hasLength(2)); - ProfileScope.reset(); - }); + test( + 'an unfollow made while the follow is in flight still goes up', + () async { + remote.hold = Completer(); + await follows.follow(_title('a')); + await pumpEventQueue(); + expect(remote.calls, ['add a']); + await follows.unfollow('a'); + await pumpEventQueue(); + remote.hold!.complete(); + remote.hold = null; + // The queue drains through real storage I/O, not only microtasks, so a + // fixed number of event-queue pumps passed locally and failed on a slower + // CI runner. Wait for the outcome itself, with a ceiling. + for ( + var i = 0; + i < 200 && (remote.calls.length < 2 || sync.pendingCount > 0); + i++ + ) { + await Future.delayed(const Duration(milliseconds: 10)); + } + expect(remote.calls, ['add a', 'remove a']); + expect(sync.pendingCount, 0); + }, + ); + + test( + 'a profile switch mid-sync keeps the old list out of the new profile', + () async { + hive.raw = [_title('mine').toJson()]; + remote.server = [_title('theirs')]; + remote.hold = Completer(); + final first = sync.fullSync(force: true); + await pumpEventQueue(); + ProfileScope.set(namespace: 'kid', remoteId: 'kid'); + remote.hold!.complete(); + remote.hold = null; + remote.server = [_title('mine')]; + expect(await first, isFalse); + expect(hive.raw.map((e) => e['contentUrl']), ['mine']); + await pumpEventQueue(); + // The new profile gets a sync of its own instead of being skipped. + expect(remote.calls.where((c) => c == 'sync'), hasLength(2)); + ProfileScope.reset(); + }, + ); group('collapse', () { test('a delete wins over whatever was queued', () { @@ -282,7 +303,10 @@ void main() { test('a patch on a queued upsert is already in it', () { expect( - FollowSyncService.collapse({'op': 'upsert'}, {'op': 'patch', 'notify': true}), + FollowSyncService.collapse( + {'op': 'upsert'}, + {'op': 'patch', 'notify': true}, + ), {'op': 'upsert'}, ); }); From c077ca50b997b14629cf0d00c5759efd29c987f7 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:56:03 +0500 Subject: [PATCH 5/7] Support: attach screenshots, and hear back as a guest Up to five screenshots on a request or a reply, picked from the gallery, scaled to 1920 px on the way in and checked against the server's limits (jpg, png or webp, 5 MB) before anything is sent. Each goes straight to R2 through a signed slot and the message carries only its key; they show as thumbnails in the thread and open full screen with zoom. A message may be screenshots alone. Every message now also carries the app language and, on Android, the FCM token, so an answer reaches a guest's device too, in its language. Co-Authored-By: Claude Opus 5.5 (1M context) --- assets/translations/ar.json | 11 +- assets/translations/ckb.json | 11 +- assets/translations/de.json | 11 +- assets/translations/en.json | 11 +- assets/translations/es.json | 11 +- assets/translations/fr.json | 11 +- assets/translations/id.json | 11 +- assets/translations/ku.json | 11 +- assets/translations/nl.json | 11 +- assets/translations/pt.json | 11 +- assets/translations/ru.json | 11 +- assets/translations/tr.json | 11 +- assets/translations/uz.json | 11 +- assets/translations/yue.json | 11 +- lib/core/di/injection.dart | 5 + lib/features/support/data/support_models.dart | 21 ++ .../support/data/support_repository.dart | 109 ++++++- .../presentation/pages/support_new_page.dart | 57 +++- .../pages/support_ticket_page.dart | 180 ++++++++---- .../widgets/support_screenshots.dart | 271 ++++++++++++++++++ .../support/support_repository_test.dart | 115 +++++++- 21 files changed, 835 insertions(+), 77 deletions(-) create mode 100644 lib/features/support/presentation/widgets/support_screenshots.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 259813e8..de24ca90 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -2998,6 +2998,15 @@ "error_too_many": "طلبات كثيرة الآن — انتظر قليلاً.", "error_short": "يرجى كتابة بضع كلمات إضافية.", "error_thread_full": "هذه المحادثة طويلة جداً — ابدأ طلباً جديداً.", - "error_not_found": "هذا الطلب لم يعد موجوداً." + "error_not_found": "هذا الطلب لم يعد موجوداً.", + "screenshots_title": "لقطات الشاشة (اختياري)", + "screenshots_hint": "حتى 5 صور بصيغة jpg أو png أو webp، بحجم 5 ميغابايت لكل منها. تساعدنا على رؤية المشكلة.", + "screenshots_skipped": "تم تخطي بعض الصور: فقط jpg أو png أو webp حتى 5 ميغابايت.", + "uploading": "جارٍ رفع لقطات الشاشة {}/{}…", + "attach": "إرفاق لقطة شاشة", + "error_attachment": "تعذّر رفع لقطة الشاشة. حاول مرة أخرى.", + "error_attachment_type": "صور jpg أو png أو webp فقط.", + "error_attachment_size": "يجب ألا يتجاوز حجم كل صورة 5 ميغابايت.", + "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة." } } diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index a49d98e0..6a412594 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -2994,6 +2994,15 @@ "error_too_many": "ئێستا داواکاری زۆرە — کەمێک چاوەڕێ بکە.", "error_short": "تکایە چەند وشەیەکی تر بنووسە.", "error_thread_full": "ئەم گفتوگۆیە زۆر درێژە — داواکارییەکی نوێ بکەرەوە.", - "error_not_found": "ئەم داواکارییە ئیتر بوونی نییە." + "error_not_found": "ئەم داواکارییە ئیتر بوونی نییە.", + "screenshots_title": "وێنەی شاشە (ئارەزوومەندانە)", + "screenshots_hint": "تا 5 وێنە، jpg یان png یان webp، هەر یەکە تا 5 MB. یارمەتیمان دەدەن کێشەکە ببینین.", + "screenshots_skipped": "هەندێک وێنە پشتگوێ خران: تەنها jpg یان png یان webp تا 5 MB.", + "uploading": "وێنەکانی شاشە باردەکرێن {}/{}…", + "attach": "وێنەی شاشە هاوپێچ بکە", + "error_attachment": "وێنەی شاشە بار نەکرا. دووبارە هەوڵ بدەرەوە.", + "error_attachment_type": "تەنها وێنەی jpg یان png یان webp.", + "error_attachment_size": "هەر وێنەیەک دەبێت 5 MB یان کەمتر بێت.", + "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە." } } diff --git a/assets/translations/de.json b/assets/translations/de.json index 1fbb7dbb..fb3dc944 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -2994,6 +2994,15 @@ "error_too_many": "Gerade zu viele Anfragen — warte bitte kurz.", "error_short": "Schreib bitte noch ein paar Worte mehr.", "error_thread_full": "Diese Unterhaltung ist zu lang — starte bitte eine neue Anfrage.", - "error_not_found": "Diese Anfrage gibt es nicht mehr." + "error_not_found": "Diese Anfrage gibt es nicht mehr.", + "screenshots_title": "Screenshots (optional)", + "screenshots_hint": "Bis zu 5 Bilder, jpg, png oder webp, je bis 5 MB. Sie helfen uns, das Problem zu sehen.", + "screenshots_skipped": "Einige Bilder wurden übersprungen: nur jpg, png oder webp bis 5 MB.", + "uploading": "Screenshots werden hochgeladen {}/{}…", + "attach": "Screenshot anhängen", + "error_attachment": "Ein Screenshot konnte nicht hochgeladen werden. Versuch es noch einmal.", + "error_attachment_type": "Nur jpg-, png- oder webp-Bilder.", + "error_attachment_size": "Jedes Bild darf höchstens 5 MB groß sein.", + "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht." } } diff --git a/assets/translations/en.json b/assets/translations/en.json index 82a459e8..ca262b6a 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -2994,6 +2994,15 @@ "error_too_many": "Too many requests right now — please wait a little.", "error_short": "Please write a few more words.", "error_thread_full": "This conversation is too long — please start a new request.", - "error_not_found": "This request no longer exists." + "error_not_found": "This request no longer exists.", + "screenshots_title": "Screenshots (optional)", + "screenshots_hint": "Up to 5 images, jpg, png or webp, 5 MB each. They help us see the problem.", + "screenshots_skipped": "Some images were skipped: only jpg, png or webp up to 5 MB.", + "uploading": "Uploading screenshots {}/{}…", + "attach": "Attach screenshot", + "error_attachment": "A screenshot couldn't be uploaded. Please try again.", + "error_attachment_type": "Only jpg, png or webp images.", + "error_attachment_size": "Each image must be 5 MB or less.", + "error_attachment_count": "At most 5 screenshots per message." } } diff --git a/assets/translations/es.json b/assets/translations/es.json index 15924267..ecf3ea56 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -2994,6 +2994,15 @@ "error_too_many": "Demasiadas solicitudes ahora: espera un poco.", "error_short": "Escribe unas palabras más, por favor.", "error_thread_full": "Esta conversación es demasiado larga: abre una nueva solicitud.", - "error_not_found": "Esta solicitud ya no existe." + "error_not_found": "Esta solicitud ya no existe.", + "screenshots_title": "Capturas de pantalla (opcional)", + "screenshots_hint": "Hasta 5 imágenes, jpg, png o webp, de 5 MB cada una. Nos ayudan a ver el problema.", + "screenshots_skipped": "Se omitieron algunas imágenes: solo jpg, png o webp de hasta 5 MB.", + "uploading": "Subiendo capturas {}/{}…", + "attach": "Adjuntar captura", + "error_attachment": "No se pudo subir una captura. Inténtalo de nuevo.", + "error_attachment_type": "Solo imágenes jpg, png o webp.", + "error_attachment_size": "Cada imagen debe pesar 5 MB o menos.", + "error_attachment_count": "Como máximo 5 capturas por mensaje." } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index cb8cec3c..331b849a 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -2994,6 +2994,15 @@ "error_too_many": "Trop de demandes pour l’instant — patiente un peu.", "error_short": "Écris quelques mots de plus, s’il te plaît.", "error_thread_full": "Cette conversation est trop longue — ouvre une nouvelle demande.", - "error_not_found": "Cette demande n’existe plus." + "error_not_found": "Cette demande n’existe plus.", + "screenshots_title": "Captures d’écran (facultatif)", + "screenshots_hint": "Jusqu’à 5 images, jpg, png ou webp, 5 Mo chacune. Elles nous aident à voir le problème.", + "screenshots_skipped": "Certaines images ont été ignorées : seulement jpg, png ou webp jusqu’à 5 Mo.", + "uploading": "Envoi des captures {}/{}…", + "attach": "Joindre une capture", + "error_attachment": "Une capture n’a pas pu être envoyée. Réessaie.", + "error_attachment_type": "Images jpg, png ou webp uniquement.", + "error_attachment_size": "Chaque image doit faire 5 Mo maximum.", + "error_attachment_count": "5 captures maximum par message." } } diff --git a/assets/translations/id.json b/assets/translations/id.json index 37bdc46a..1b27b05e 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -2994,6 +2994,15 @@ "error_too_many": "Terlalu banyak permintaan — tunggu sebentar.", "error_short": "Tulis beberapa kata lagi, ya.", "error_thread_full": "Percakapan ini terlalu panjang — buat permintaan baru.", - "error_not_found": "Permintaan ini sudah tidak ada." + "error_not_found": "Permintaan ini sudah tidak ada.", + "screenshots_title": "Tangkapan layar (opsional)", + "screenshots_hint": "Hingga 5 gambar, jpg, png, atau webp, masing-masing maks. 5 MB. Membantu kami melihat masalahnya.", + "screenshots_skipped": "Beberapa gambar dilewati: hanya jpg, png, atau webp hingga 5 MB.", + "uploading": "Mengunggah tangkapan layar {}/{}…", + "attach": "Lampirkan tangkapan layar", + "error_attachment": "Tangkapan layar gagal diunggah. Coba lagi.", + "error_attachment_type": "Hanya gambar jpg, png, atau webp.", + "error_attachment_size": "Setiap gambar maksimal 5 MB.", + "error_attachment_count": "Maksimal 5 tangkapan layar per pesan." } } diff --git a/assets/translations/ku.json b/assets/translations/ku.json index c75cb046..6fd8f196 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -2994,6 +2994,15 @@ "error_too_many": "Niha gelek daxwaz hene — hinekî bisekine.", "error_short": "Ji kerema xwe çend peyvên din binivîse.", "error_thread_full": "Ev axaftin pir dirêj e — daxwazeke nû veke.", - "error_not_found": "Ev daxwaz êdî tune ye." + "error_not_found": "Ev daxwaz êdî tune ye.", + "screenshots_title": "Dîmenên ekranê (vebijarkî)", + "screenshots_hint": "Heta 5 wêne, jpg, png an webp, her yek heta 5 MB. Alîkariya me dikin ku em pirsgirêkê bibînin.", + "screenshots_skipped": "Hin wêne hatin derbaskirin: tenê jpg, png an webp heta 5 MB.", + "uploading": "Dîmenên ekranê têne barkirin {}/{}…", + "attach": "Dîmena ekranê pê ve bike", + "error_attachment": "Dîmena ekranê nehat barkirin. Dîsa biceribîne.", + "error_attachment_type": "Tenê wêneyên jpg, png an webp.", + "error_attachment_size": "Divê her wêne herî zêde 5 MB be.", + "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê." } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index eb20315d..b6995b8c 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -2994,6 +2994,15 @@ "error_too_many": "Te veel verzoeken — wacht even.", "error_short": "Schrijf nog een paar woorden.", "error_thread_full": "Dit gesprek is te lang — begin een nieuwe vraag.", - "error_not_found": "Deze vraag bestaat niet meer." + "error_not_found": "Deze vraag bestaat niet meer.", + "screenshots_title": "Screenshots (optioneel)", + "screenshots_hint": "Tot 5 afbeeldingen, jpg, png of webp, elk tot 5 MB. Zo zien we het probleem.", + "screenshots_skipped": "Sommige afbeeldingen zijn overgeslagen: alleen jpg, png of webp tot 5 MB.", + "uploading": "Screenshots uploaden {}/{}…", + "attach": "Screenshot bijvoegen", + "error_attachment": "Een screenshot kon niet worden geüpload. Probeer het opnieuw.", + "error_attachment_type": "Alleen jpg-, png- of webp-afbeeldingen.", + "error_attachment_size": "Elke afbeelding mag maximaal 5 MB zijn.", + "error_attachment_count": "Maximaal 5 screenshots per bericht." } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 4a72e7d0..93070a99 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -2994,6 +2994,15 @@ "error_too_many": "Muitas solicitações agora — espere um pouco.", "error_short": "Escreva mais algumas palavras, por favor.", "error_thread_full": "Esta conversa ficou longa demais — abra uma nova solicitação.", - "error_not_found": "Esta solicitação não existe mais." + "error_not_found": "Esta solicitação não existe mais.", + "screenshots_title": "Capturas de tela (opcional)", + "screenshots_hint": "Até 5 imagens, jpg, png ou webp, de até 5 MB cada. Ajudam a gente a ver o problema.", + "screenshots_skipped": "Algumas imagens foram ignoradas: só jpg, png ou webp de até 5 MB.", + "uploading": "Enviando capturas {}/{}…", + "attach": "Anexar captura", + "error_attachment": "Não foi possível enviar uma captura. Tente de novo.", + "error_attachment_type": "Só imagens jpg, png ou webp.", + "error_attachment_size": "Cada imagem pode ter no máximo 5 MB.", + "error_attachment_count": "No máximo 5 capturas por mensagem." } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index d6b7ce21..9165b5d0 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -2995,6 +2995,15 @@ "error_too_many": "Слишком много запросов — подождите немного.", "error_short": "Пожалуйста, напишите чуть подробнее.", "error_thread_full": "Переписка слишком длинная — создайте новое обращение.", - "error_not_found": "Это обращение больше не существует." + "error_not_found": "Это обращение больше не существует.", + "screenshots_title": "Скриншоты (необязательно)", + "screenshots_hint": "До 5 изображений: jpg, png или webp, до 5 МБ каждое. Помогают увидеть проблему.", + "screenshots_skipped": "Некоторые изображения пропущены: только jpg, png или webp до 5 МБ.", + "uploading": "Загрузка скриншотов {}/{}…", + "attach": "Прикрепить скриншот", + "error_attachment": "Не удалось загрузить скриншот. Попробуйте ещё раз.", + "error_attachment_type": "Только изображения jpg, png или webp.", + "error_attachment_size": "Каждое изображение — не больше 5 МБ.", + "error_attachment_count": "Не больше 5 скриншотов в сообщении." } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index e66f0029..972fedcd 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -2994,6 +2994,15 @@ "error_too_many": "Şu an çok fazla istek var — biraz bekle.", "error_short": "Lütfen birkaç kelime daha yaz.", "error_thread_full": "Bu konuşma çok uzun — yeni bir talep aç.", - "error_not_found": "Bu talep artık mevcut değil." + "error_not_found": "Bu talep artık mevcut değil.", + "screenshots_title": "Ekran görüntüleri (isteğe bağlı)", + "screenshots_hint": "En fazla 5 görsel; jpg, png veya webp, her biri en çok 5 MB. Sorunu görmemize yardım eder.", + "screenshots_skipped": "Bazı görseller atlandı: yalnızca 5 MB’a kadar jpg, png veya webp.", + "uploading": "Ekran görüntüleri yükleniyor {}/{}…", + "attach": "Ekran görüntüsü ekle", + "error_attachment": "Bir ekran görüntüsü yüklenemedi. Tekrar dene.", + "error_attachment_type": "Yalnızca jpg, png veya webp görseller.", + "error_attachment_size": "Her görsel en fazla 5 MB olmalı.", + "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü." } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index b768c3ed..7904ca9d 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -2994,6 +2994,15 @@ "error_too_many": "Hozir so‘rovlar juda ko‘p — biroz kuting.", "error_short": "Iltimos, yana bir necha so‘z yozing.", "error_thread_full": "Bu yozishma juda uzun — yangi murojaat oching.", - "error_not_found": "Bu murojaat endi mavjud emas." + "error_not_found": "Bu murojaat endi mavjud emas.", + "screenshots_title": "Skrinshotlar (ixtiyoriy)", + "screenshots_hint": "5 tagacha rasm, jpg, png yoki webp, har biri 5 MB gacha. Muammoni ko‘rishimizga yordam beradi.", + "screenshots_skipped": "Ba’zi rasmlar qo‘shilmadi: faqat 5 MB gacha jpg, png yoki webp.", + "uploading": "Skrinshotlar yuklanmoqda {}/{}…", + "attach": "Skrinshot biriktirish", + "error_attachment": "Skrinshotni yuklab bo‘lmadi. Qaytadan urinib ko‘ring.", + "error_attachment_type": "Faqat jpg, png yoki webp rasm.", + "error_attachment_size": "Har bir rasm 5 MB dan oshmasin.", + "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot." } } diff --git a/assets/translations/yue.json b/assets/translations/yue.json index ac264f84..c2ea56c0 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -2994,6 +2994,15 @@ "error_too_many": "而家太多要求 —— 請等陣。", "error_short": "請再寫多幾個字。", "error_thread_full": "呢段對話太長 —— 請開過新查詢。", - "error_not_found": "呢個查詢已經唔存在。" + "error_not_found": "呢個查詢已經唔存在。", + "screenshots_title": "截圖(可選)", + "screenshots_hint": "最多 5 張圖,jpg、png 或 webp,每張 5 MB 以內。幫我哋睇清楚個問題。", + "screenshots_skipped": "有啲圖冇加到:只接受 5 MB 以內嘅 jpg、png 或 webp。", + "uploading": "上載緊截圖 {}/{}…", + "attach": "附加截圖", + "error_attachment": "截圖上載唔到,請再試一次。", + "error_attachment_type": "只接受 jpg、png 或 webp 圖片。", + "error_attachment_size": "每張圖唔可以大過 5 MB。", + "error_attachment_count": "每條訊息最多 5 張截圖。" } } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 153c900f..8a4a9c4d 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:soplay/core/cloudstream/cloudstream_channel.dart'; import 'package:soplay/core/aniyomi/aniyomi_channel.dart'; import 'package:soplay/core/manga/manga_channel.dart'; @@ -790,6 +791,10 @@ Future configureDependencies() async { () => SupportRepository( dio: getIt(), deviceKey: () => getIt().supportDeviceKey, + // Push is Android-only in this app (NotificationService.setup). + pushToken: () async => Platform.isAndroid + ? await FirebaseMessaging.instance.getToken() + : null, ), ); getIt.registerSingleton( diff --git a/lib/features/support/data/support_models.dart b/lib/features/support/data/support_models.dart index 13f643f5..bd2f3bf8 100644 --- a/lib/features/support/data/support_models.dart +++ b/lib/features/support/data/support_models.dart @@ -12,23 +12,44 @@ SupportStatus supportStatusOf(String? name) => SupportStatus.values.firstWhere( orElse: () => SupportStatus.open, ); +/// A screenshot on a message. The URL is signed and short-lived: it is +/// fetched again with the ticket, never kept. +class SupportAttachment { + const SupportAttachment({required this.url, this.size = 0}); + + final String url; + final int size; +} + class SupportMessage { const SupportMessage({ required this.id, required this.fromSupport, required this.body, required this.at, + this.attachments = const [], }); final String id; final bool fromSupport; + + /// Empty when the message is only its screenshots. final String body; final DateTime at; + final List attachments; factory SupportMessage.fromJson(Map json) => SupportMessage( id: json['id']?.toString() ?? '', fromSupport: json['from'] == 'admin', body: json['body']?.toString() ?? '', + attachments: [ + for (final a in (json['attachments'] as List? ?? const [])) + if (a is Map && a['url'] is String) + SupportAttachment( + url: a['url'] as String, + size: (a['size'] as num?)?.toInt() ?? 0, + ), + ], at: DateTime.tryParse(json['at']?.toString() ?? '')?.toLocal() ?? DateTime.now(), diff --git a/lib/features/support/data/support_repository.dart b/lib/features/support/data/support_repository.dart index 34e8df94..d292fd25 100644 --- a/lib/features/support/data/support_repository.dart +++ b/lib/features/support/data/support_repository.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:soplay/features/support/data/support_models.dart'; @@ -8,13 +10,87 @@ import 'package:soplay/features/support/data/support_models.dart'; /// the app is signed in to. A guest's tickets belong to the key, and a guest /// who signs in later still sees what they wrote before. class SupportRepository { - /// [deviceKey] is HiveService.supportDeviceKey in the app. - SupportRepository({required Dio dio, required String Function() deviceKey}) - : _dio = dio, - _deviceKey = deviceKey; + /// [deviceKey] is HiveService.supportDeviceKey in the app; [pushToken] the + /// device's FCM token, sent with each message so that an answer reaches a + /// guest too; [uploads] the bare client screenshots go to R2 with. + SupportRepository({ + required Dio dio, + required String Function() deviceKey, + Future Function()? pushToken, + Dio? uploads, + }) : _dio = dio, + _deviceKey = deviceKey, + _pushToken = pushToken, + _uploads = uploads ?? Dio(); final Dio _dio; final String Function() _deviceKey; + final Future Function()? _pushToken; + + /// No base URL and no interceptors: the presigned URL carries its own + /// authorisation, and the app's bearer token has no business reaching R2. + final Dio _uploads; + + /// The server's limits (models/SupportTicket.js). + static const int maxAttachments = 5; + static const int maxAttachmentBytes = 5 * 1024 * 1024; + + static String? contentTypeFor(String path) { + final p = path.toLowerCase(); + if (p.endsWith('.jpg') || p.endsWith('.jpeg')) return 'image/jpeg'; + if (p.endsWith('.png')) return 'image/png'; + if (p.endsWith('.webp')) return 'image/webp'; + return null; + } + + Future _token() async { + try { + return await _pushToken?.call(); + } catch (_) { + return null; + } + } + + /// Puts one screenshot into R2 and returns the key a message carries it by. + Future uploadScreenshot(File file) async { + final type = contentTypeFor(file.path); + if (type == null) { + throw SupportException('support.error_attachment_type'.tr()); + } + final size = await file.length(); + if (size > maxAttachmentBytes) { + throw SupportException('support.error_attachment_size'.tr()); + } + final slot = await _guard(() async { + final res = await _dio.post>( + '/support/uploads', + data: {'contentType': type, 'size': size}, + options: _options, + ); + return res.data ?? const {}; + }); + final url = slot['uploadUrl']; + final key = slot['key']; + if (url is! String || key is! String) { + throw SupportException('support.error_attachment'.tr()); + } + try { + // The length is signed into the URL: R2 refuses any other. + await _uploads.put( + url, + data: file.openRead(), + options: Options( + headers: { + Headers.contentTypeHeader: type, + Headers.contentLengthHeader: size, + }, + ), + ); + } on DioException { + throw SupportException('support.error_attachment'.tr()); + } + return key; + } Options get _options => Options(headers: {'X-Support-Key': _deviceKey()}); @@ -48,7 +124,10 @@ class SupportRepository { required String message, String? contact, Map? diagnostics, + List attachments = const [], + String? language, }) => _guard(() async { + final token = await _token(); final res = await _dio.post>( '/support/tickets', data: { @@ -58,16 +137,30 @@ class SupportRepository { 'contact': contact.trim(), if (diagnostics != null && diagnostics.isNotEmpty) 'diagnostics': diagnostics, + if (attachments.isNotEmpty) 'attachments': attachments, + 'language': ?language, + 'pushToken': ?token, }, options: _options, ); return _ticket(res.data); }); - Future reply(String id, String message) => _guard(() async { + Future reply( + String id, + String message, { + List attachments = const [], + String? language, + }) => _guard(() async { + final token = await _token(); final res = await _dio.post>( '/support/tickets/$id/messages', - data: {'message': message}, + data: { + 'message': message, + if (attachments.isNotEmpty) 'attachments': attachments, + 'language': ?language, + 'pushToken': ?token, + }, options: _options, ); return _ticket(res.data); @@ -100,6 +193,10 @@ class SupportRepository { (_, 'SUPPORT_MESSAGE') => 'support.error_short', (_, 'SUPPORT_THREAD_FULL') => 'support.error_thread_full', (_, 'SUPPORT_NOT_FOUND') => 'support.error_not_found', + (_, 'SUPPORT_ATTACHMENT_TYPE') => 'support.error_attachment_type', + (_, 'SUPPORT_ATTACHMENT_SIZE') => 'support.error_attachment_size', + (_, 'SUPPORT_ATTACHMENT_COUNT') => 'support.error_attachment_count', + (_, 'SUPPORT_ATTACHMENT') => 'support.error_attachment', _ => 'support.error_generic', }; throw SupportException(key.tr(), code: code); diff --git a/lib/features/support/presentation/pages/support_new_page.dart b/lib/features/support/presentation/pages/support_new_page.dart index b787bd6c..f92365ca 100644 --- a/lib/features/support/presentation/pages/support_new_page.dart +++ b/lib/features/support/presentation/pages/support_new_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -10,6 +12,7 @@ import 'package:soplay/features/support/data/support_diagnostics.dart'; import 'package:soplay/features/support/data/support_models.dart'; import 'package:soplay/features/support/data/support_repository.dart'; import 'package:soplay/features/support/presentation/support_style.dart'; +import 'package:soplay/features/support/presentation/widgets/support_screenshots.dart'; /// A new request to support. /// @@ -37,10 +40,24 @@ class _SupportNewPageState extends State { String? _error; Map? _diagnostics; Future? _collecting; + final List _shots = []; + + /// "Uploading screenshots 2/5" while they go up, before the message does. + String? _progress; bool get _guest => !_hive.isLoggedIn; bool get _canSend => - !_sending && _category != null && _message.text.trim().length >= 3; + !_sending && + _category != null && + (_message.text.trim().length >= 3 || _shots.isNotEmpty); + + Future _pickShots() async { + final picked = await pickSupportScreenshots( + context, + SupportRepository.maxAttachments - _shots.length, + ); + if (picked.isNotEmpty && mounted) setState(() => _shots.addAll(picked)); + } @override void initState() { @@ -81,11 +98,24 @@ class _SupportNewPageState extends State { if (_attach) await _collecting; if (!mounted) return; try { + final keys = []; + for (var i = 0; i < _shots.length; i++) { + setState( + () => _progress = 'support.uploading'.tr( + args: ['${i + 1}', '${_shots.length}'], + ), + ); + keys.add(await _repo.uploadScreenshot(_shots[i])); + } + if (!mounted) return; + setState(() => _progress = null); final ticket = await _repo.create( category: _category!, message: _message.text.trim(), contact: _guest ? _contact.text : null, diagnostics: _attach ? _diagnostics : null, + attachments: keys, + language: context.locale.languageCode, ); if (!mounted) return; ScaffoldMessenger.of( @@ -96,6 +126,7 @@ class _SupportNewPageState extends State { if (mounted) { setState(() { _sending = false; + _progress = null; _error = e.message; }); } @@ -150,6 +181,20 @@ class _SupportNewPageState extends State { maxLength: 2000, enabled: !_sending, ), + const SizedBox(height: 4), + SettingsLabel('support.screenshots_title'.tr()), + ScreenshotStrip( + files: _shots, + onRemove: (i) => setState(() => _shots.removeAt(i)), + onAdd: !_sending && _shots.length < SupportRepository.maxAttachments + ? _pickShots + : null, + ), + const SizedBox(height: 6), + Text( + 'support.screenshots_hint'.tr(), + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), if (_guest) ...[ const SizedBox(height: 16), SettingsLabel('support.contact_label'.tr()), @@ -184,6 +229,16 @@ class _SupportNewPageState extends State { style: const TextStyle(color: AppColors.errorLight, fontSize: 13), ), ], + if (_progress != null) ...[ + const SizedBox(height: 12), + Text( + _progress!, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + ), + ), + ], const SizedBox(height: 20), AppPrimaryButton( label: 'support.send'.tr(), diff --git a/lib/features/support/presentation/pages/support_ticket_page.dart b/lib/features/support/presentation/pages/support_ticket_page.dart index fc693333..6b3c498f 100644 --- a/lib/features/support/presentation/pages/support_ticket_page.dart +++ b/lib/features/support/presentation/pages/support_ticket_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/di/injection.dart'; @@ -5,6 +7,7 @@ import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/support/data/support_models.dart'; import 'package:soplay/features/support/data/support_repository.dart'; import 'package:soplay/features/support/presentation/support_style.dart'; +import 'package:soplay/features/support/presentation/widgets/support_screenshots.dart'; /// One conversation with support. Opening it marks the answer read. class SupportTicketPage extends StatefulWidget { @@ -24,6 +27,18 @@ class _SupportTicketPageState extends State { SupportTicket? _ticket; String? _error; bool _sending = false; + final List _shots = []; + + bool get _canSend => + !_sending && (_reply.text.trim().length >= 3 || _shots.isNotEmpty); + + Future _pickShots() async { + final picked = await pickSupportScreenshots( + context, + SupportRepository.maxAttachments - _shots.length, + ); + if (picked.isNotEmpty && mounted) setState(() => _shots.addAll(picked)); + } @override void initState() { @@ -63,15 +78,25 @@ class _SupportTicketPageState extends State { Future _send() async { final text = _reply.text.trim(); - if (text.length < 3 || _sending) return; + if (!_canSend) return; setState(() => _sending = true); try { - final t = await _repo.reply(widget.id, text); + final keys = [ + for (final f in _shots) await _repo.uploadScreenshot(f), + ]; + if (!mounted) return; + final t = await _repo.reply( + widget.id, + text, + attachments: keys, + language: context.locale.languageCode, + ); if (!mounted) return; _reply.clear(); setState(() { _ticket = t; _sending = false; + _shots.clear(); }); _toBottom(); } on SupportException catch (e) { @@ -186,7 +211,14 @@ class _SupportTicketPageState extends State { _Composer( controller: _reply, sending: _sending, - onSend: _reply.text.trim().length >= 3 ? _send : null, + onSend: _canSend ? _send : null, + shots: _shots, + onRemoveShot: (i) => setState(() => _shots.removeAt(i)), + onAttach: + !_sending && + _shots.length < SupportRepository.maxAttachments + ? _pickShots + : null, ), ], ), @@ -246,14 +278,22 @@ class _Bubble extends StatelessWidget { ), ), ), - SelectableText( - message.body, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14.5, - height: 1.4, + if (message.attachments.isNotEmpty) + Padding( + padding: EdgeInsets.only( + bottom: message.body.isEmpty ? 0 : 6, + ), + child: AttachmentGrid(items: message.attachments), + ), + if (message.body.isNotEmpty) + SelectableText( + message.body, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + height: 1.4, + ), ), - ), const SizedBox(height: 4), Text( supportTime(message.at), @@ -294,11 +334,17 @@ class _Composer extends StatelessWidget { required this.controller, required this.sending, required this.onSend, + required this.shots, + required this.onRemoveShot, + required this.onAttach, }); final TextEditingController controller; final bool sending; final VoidCallback? onSend; + final List shots; + final ValueChanged onRemoveShot; + final VoidCallback? onAttach; @override Widget build(BuildContext context) { @@ -315,57 +361,81 @@ class _Composer extends StatelessWidget { top: BorderSide(color: Colors.white.withValues(alpha: 0.06)), ), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: TextField( - controller: controller, - enabled: !sending, - minLines: 1, - maxLines: 5, - maxLength: 2000, - textCapitalization: TextCapitalization.sentences, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14.5, + if (shots.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ScreenshotStrip( + files: shots, + height: 84, + onRemove: sending ? (_) {} : onRemoveShot, ), - decoration: InputDecoration( - hintText: 'support.reply_hint'.tr(), - hintStyle: const TextStyle(color: AppColors.textHint), - counterText: '', - filled: true, - fillColor: AppColors.surface, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 11, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(22), - borderSide: BorderSide.none, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + IconButton( + tooltip: 'support.attach'.tr(), + onPressed: onAttach, + icon: Icon( + Icons.add_photo_alternate_outlined, + color: onAttach == null + ? AppColors.textHint + : AppColors.textSecondary, ), ), - ), - ), - const SizedBox(width: 6), - SizedBox( - width: 44, - height: 44, - child: sending - ? const Padding( - padding: EdgeInsets.all(11), - child: CircularProgressIndicator(strokeWidth: 2), - ) - : IconButton( - onPressed: onSend, - icon: Icon( - Icons.send_rounded, - color: onSend == null - ? AppColors.textHint - : AppColors.primary, + Expanded( + child: TextField( + controller: controller, + enabled: !sending, + minLines: 1, + maxLines: 5, + maxLength: 2000, + textCapitalization: TextCapitalization.sentences, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + ), + decoration: InputDecoration( + hintText: 'support.reply_hint'.tr(), + hintStyle: const TextStyle(color: AppColors.textHint), + counterText: '', + filled: true, + fillColor: AppColors.surface, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 11, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, ), ), + ), + ), + const SizedBox(width: 6), + SizedBox( + width: 44, + height: 44, + child: sending + ? const Padding( + padding: EdgeInsets.all(11), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + onPressed: onSend, + icon: Icon( + Icons.send_rounded, + color: onSend == null + ? AppColors.textHint + : AppColors.primary, + ), + ), + ), + ], ), ], ), diff --git a/lib/features/support/presentation/widgets/support_screenshots.dart b/lib/features/support/presentation/widgets/support_screenshots.dart new file mode 100644 index 00000000..5635d244 --- /dev/null +++ b/lib/features/support/presentation/widgets/support_screenshots.dart @@ -0,0 +1,271 @@ +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/support/data/support_models.dart'; +import 'package:soplay/features/support/data/support_repository.dart'; + +/// Lets the viewer choose up to [remaining] screenshots. +/// +/// Scaled down on the way in (a screenshot needs no more than 1920 px on its +/// long side to be read), which keeps them far under the 5 MB limit; anything +/// still over it, or of a type the server does not take, is left out and +/// said so. +Future> pickSupportScreenshots( + BuildContext context, + int remaining, +) async { + if (remaining <= 0) return const []; + final picker = ImagePicker(); + List picked; + try { + picked = remaining == 1 + ? [ + ?await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + ), + ] + : await picker.pickMultiImage( + limit: remaining, + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + ); + } on PlatformException { + return const []; + } + final out = []; + var skipped = 0; + for (final x in picked.take(remaining)) { + final file = File(x.path); + final ok = + SupportRepository.contentTypeFor(x.path) != null && + await file.length() <= SupportRepository.maxAttachmentBytes; + ok ? out.add(file) : skipped++; + } + if (skipped > 0 && context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('support.screenshots_skipped'.tr()))); + } + return out; +} + +/// The screenshots chosen for a message that is not sent yet. +class ScreenshotStrip extends StatelessWidget { + const ScreenshotStrip({ + super.key, + required this.files, + required this.onRemove, + this.onAdd, + this.height = 112, + }); + + final List files; + final ValueChanged onRemove; + + /// Null when no more can be added (or while sending). + final VoidCallback? onAdd; + final double height; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + for (var i = 0; i < files.length; i++) + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.file( + files[i], + width: height * 0.62, + height: height, + fit: BoxFit.cover, + cacheWidth: 240, + ), + ), + PositionedDirectional( + top: 4, + end: 4, + child: GestureDetector( + onTap: () => onRemove(i), + child: Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close_rounded, + size: 15, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + if (onAdd != null) + InkWell( + onTap: onAdd, + borderRadius: BorderRadius.circular(10), + child: Container( + width: height * 0.62, + height: height, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.white.withValues(alpha: 0.10), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.add_photo_alternate_outlined, + color: AppColors.textSecondary, + ), + const SizedBox(height: 4), + Text( + '${files.length}/${SupportRepository.maxAttachments}', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// Screenshots on a sent message; a tap opens them full screen. +class AttachmentGrid extends StatelessWidget { + const AttachmentGrid({super.key, required this.items}); + + final List items; + + @override + Widget build(BuildContext context) { + final single = items.length == 1; + return Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (var i = 0; i < items.length; i++) + GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SupportImageViewer(items: items, initial: i), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + items[i].url, + width: single ? 170 : 96, + height: single ? 280 : 156, + fit: BoxFit.cover, + cacheWidth: single ? 510 : 288, + loadingBuilder: (context, child, progress) => progress == null + ? child + : Container( + width: single ? 170 : 96, + height: single ? 280 : 156, + color: Colors.white.withValues(alpha: 0.05), + alignment: Alignment.center, + child: const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + errorBuilder: (context, _, _) => Container( + width: single ? 170 : 96, + height: single ? 280 : 156, + color: Colors.white.withValues(alpha: 0.05), + alignment: Alignment.center, + child: const Icon( + Icons.broken_image_outlined, + color: AppColors.textHint, + ), + ), + ), + ), + ), + ], + ); + } +} + +class SupportImageViewer extends StatefulWidget { + const SupportImageViewer({ + super.key, + required this.items, + required this.initial, + }); + + final List items; + final int initial; + + @override + State createState() => _SupportImageViewerState(); +} + +class _SupportImageViewerState extends State { + late final PageController _pages = PageController( + initialPage: widget.initial, + ); + late int _index = widget.initial; + + @override + void dispose() { + _pages.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + elevation: 0, + title: Text( + '${_index + 1} / ${widget.items.length}', + style: const TextStyle(fontSize: 15), + ), + ), + body: PageView.builder( + controller: _pages, + itemCount: widget.items.length, + onPageChanged: (i) => setState(() => _index = i), + itemBuilder: (context, i) => InteractiveViewer( + maxScale: 5, + child: Center( + child: Image.network(widget.items[i].url, fit: BoxFit.contain), + ), + ), + ), + ); + } +} diff --git a/test/features/support/support_repository_test.dart b/test/features/support/support_repository_test.dart index 5df9599a..88f415c7 100644 --- a/test/features/support/support_repository_test.dart +++ b/test/features/support/support_repository_test.dart @@ -13,13 +13,27 @@ void main() { late HttpServer server; final seen = <({String method, String path, String? key, Object? body})>[]; var status = 200; + final puts = <({String path, String? type, int length, int bytes})>[]; Map answer = {}; setUp(() async { seen.clear(); + puts.clear(); status = 200; server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); server.listen((req) async { + if (req.method == 'PUT') { + final bytes = await req.fold>([], (a, b) => a..addAll(b)); + puts.add(( + path: req.uri.path, + type: req.headers.contentType?.mimeType, + length: req.headers.contentLength, + bytes: bytes.length, + )); + req.response.statusCode = 200; + await req.response.close(); + return; + } final raw = await utf8.decoder.bind(req).join(); seen.add(( method: req.method, @@ -37,9 +51,10 @@ void main() { tearDown(() => server.close(force: true)); - SupportRepository repo() => SupportRepository( + SupportRepository repo({String? token}) => SupportRepository( dio: Dio(BaseOptions(baseUrl: 'http://127.0.0.1:${server.port}/api')), deviceKey: () => 'k' * 43, + pushToken: token == null ? null : () async => token, ); final ticketJson = { @@ -144,5 +159,103 @@ void main() { ); }, ); + + test( + 'a screenshot goes to its signed slot with its own size and type', + () async { + final dir = await Directory.systemTemp.createTemp('sozo_shot_'); + addTearDown(() => dir.delete(recursive: true)); + final file = File('${dir.path}/shot.png') + ..writeAsBytesSync(List.filled(2048, 7)); + answer = { + 'uploadUrl': + 'http://127.0.0.1:${server.port}/put/support/k/abc/1.png', + 'key': 'support/k/abc/1.png', + }; + final key = await repo().uploadScreenshot(file); + expect(key, 'support/k/abc/1.png'); + final slot = seen.single; + expect(slot.path, '/api/support/uploads'); + expect(slot.key, 'k' * 43); + expect(slot.body, {'contentType': 'image/png', 'size': 2048}); + expect(puts.single.path, '/put/support/k/abc/1.png'); + expect(puts.single.type, 'image/png'); + expect(puts.single.length, 2048); + expect(puts.single.bytes, 2048); + }, + ); + + test( + 'a file the server would refuse is refused before any request', + () async { + final dir = await Directory.systemTemp.createTemp('sozo_shot_'); + addTearDown(() => dir.delete(recursive: true)); + final gif = File('${dir.path}/a.gif')..writeAsBytesSync([1, 2, 3]); + await expectLater( + repo().uploadScreenshot(gif), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'support.error_attachment_type', + ), + ), + ); + final big = File('${dir.path}/big.jpg') + ..writeAsBytesSync( + List.filled(SupportRepository.maxAttachmentBytes + 1, 0), + ); + await expectLater( + repo().uploadScreenshot(big), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'support.error_attachment_size', + ), + ), + ); + expect(seen, isEmpty); + }, + ); + + test( + 'a message carries its screenshots, the language and the push token', + () async { + answer = {'ticket': ticketJson}; + await repo( + token: 'fcm-token-0123456789-abcdef', + ).reply('t1', '', attachments: ['support/k/abc/1.png'], language: 'ru'); + final body = seen.single.body as Map; + expect(body['attachments'], ['support/k/abc/1.png']); + expect(body['language'], 'ru'); + expect(body['pushToken'], 'fcm-token-0123456789-abcdef'); + seen.clear(); + await repo().create( + category: SupportCategory.bug, + message: 'No token here', + ); + expect((seen.single.body as Map).containsKey('pushToken'), isFalse); + }, + ); + + test('screenshots on a message read back with their signed URLs', () { + final m = SupportMessage.fromJson({ + 'id': 'm', + 'from': 'user', + 'body': '', + 'at': '2026-09-26T09:00:00Z', + 'attachments': [ + { + 'url': 'https://r2/get/a.jpg', + 'contentType': 'image/jpeg', + 'size': 900, + }, + {'contentType': 'image/jpeg'}, + ], + }); + expect(m.attachments.map((a) => a.url), ['https://r2/get/a.jpg']); + expect(m.body, isEmpty); + }); }); } From eea4882a59a79ab3ed1dc22a1e2fb1196ced3f5c Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:10:32 +0500 Subject: [PATCH 6/7] Offer to write to support from error screens Home, detail, search, shorts, my list and notifications now show a "Keeps happening? Write to support" link under their error view. It opens a new ticket prefilled with the screen, provider, content and the error text, which goes into the (opt-in, redacted) diagnostics. Co-Authored-By: Claude Opus 5.5 (1M context) --- assets/translations/ar.json | 3 +- assets/translations/ckb.json | 3 +- assets/translations/de.json | 3 +- assets/translations/en.json | 3 +- assets/translations/es.json | 3 +- assets/translations/fr.json | 3 +- assets/translations/id.json | 3 +- assets/translations/ku.json | 3 +- assets/translations/nl.json | 3 +- assets/translations/pt.json | 3 +- assets/translations/ru.json | 3 +- assets/translations/tr.json | 3 +- assets/translations/uz.json | 3 +- assets/translations/yue.json | 3 +- .../presentation/pages/detail_page.dart | 18 +++++ .../widgets/home_state_views.dart | 9 +++ .../widgets/my_list_state_views.dart | 7 ++ .../pages/notifications_page.dart | 2 + .../widgets/search_state_views.dart | 8 +++ .../widgets/shorts_state_views.dart | 7 ++ .../support/data/support_diagnostics.dart | 4 ++ lib/features/support/data/support_models.dart | 4 ++ .../widgets/support_suggestion.dart | 46 +++++++++++++ .../support/support_suggestion_test.dart | 67 +++++++++++++++++++ 24 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 lib/features/support/presentation/widgets/support_suggestion.dart create mode 100644 test/features/support/support_suggestion_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index de24ca90..221247cf 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -3007,6 +3007,7 @@ "error_attachment": "تعذّر رفع لقطة الشاشة. حاول مرة أخرى.", "error_attachment_type": "صور jpg أو png أو webp فقط.", "error_attachment_size": "يجب ألا يتجاوز حجم كل صورة 5 ميغابايت.", - "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة." + "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة.", + "suggest": "تتكرر المشكلة؟ راسل الدعم" } } diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6a412594..f97db42b 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -3003,6 +3003,7 @@ "error_attachment": "وێنەی شاشە بار نەکرا. دووبارە هەوڵ بدەرەوە.", "error_attachment_type": "تەنها وێنەی jpg یان png یان webp.", "error_attachment_size": "هەر وێنەیەک دەبێت 5 MB یان کەمتر بێت.", - "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە." + "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە.", + "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە" } } diff --git a/assets/translations/de.json b/assets/translations/de.json index fb3dc944..8cc3ee27 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -3003,6 +3003,7 @@ "error_attachment": "Ein Screenshot konnte nicht hochgeladen werden. Versuch es noch einmal.", "error_attachment_type": "Nur jpg-, png- oder webp-Bilder.", "error_attachment_size": "Jedes Bild darf höchstens 5 MB groß sein.", - "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht." + "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht.", + "suggest": "Passiert das öfter? Schreib dem Support" } } diff --git a/assets/translations/en.json b/assets/translations/en.json index ca262b6a..b7828f39 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -3003,6 +3003,7 @@ "error_attachment": "A screenshot couldn't be uploaded. Please try again.", "error_attachment_type": "Only jpg, png or webp images.", "error_attachment_size": "Each image must be 5 MB or less.", - "error_attachment_count": "At most 5 screenshots per message." + "error_attachment_count": "At most 5 screenshots per message.", + "suggest": "Keeps happening? Write to support" } } diff --git a/assets/translations/es.json b/assets/translations/es.json index ecf3ea56..e45868f1 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -3003,6 +3003,7 @@ "error_attachment": "No se pudo subir una captura. Inténtalo de nuevo.", "error_attachment_type": "Solo imágenes jpg, png o webp.", "error_attachment_size": "Cada imagen debe pesar 5 MB o menos.", - "error_attachment_count": "Como máximo 5 capturas por mensaje." + "error_attachment_count": "Como máximo 5 capturas por mensaje.", + "suggest": "¿Sigue pasando? Escribe a soporte" } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 331b849a..e1a4c3dd 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -3003,6 +3003,7 @@ "error_attachment": "Une capture n’a pas pu être envoyée. Réessaie.", "error_attachment_type": "Images jpg, png ou webp uniquement.", "error_attachment_size": "Chaque image doit faire 5 Mo maximum.", - "error_attachment_count": "5 captures maximum par message." + "error_attachment_count": "5 captures maximum par message.", + "suggest": "Ça se reproduit ? Écris au support" } } diff --git a/assets/translations/id.json b/assets/translations/id.json index 1b27b05e..d3996d02 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -3003,6 +3003,7 @@ "error_attachment": "Tangkapan layar gagal diunggah. Coba lagi.", "error_attachment_type": "Hanya gambar jpg, png, atau webp.", "error_attachment_size": "Setiap gambar maksimal 5 MB.", - "error_attachment_count": "Maksimal 5 tangkapan layar per pesan." + "error_attachment_count": "Maksimal 5 tangkapan layar per pesan.", + "suggest": "Terus terjadi? Hubungi dukungan" } } diff --git a/assets/translations/ku.json b/assets/translations/ku.json index 6fd8f196..556dc6e5 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -3003,6 +3003,7 @@ "error_attachment": "Dîmena ekranê nehat barkirin. Dîsa biceribîne.", "error_attachment_type": "Tenê wêneyên jpg, png an webp.", "error_attachment_size": "Divê her wêne herî zêde 5 MB be.", - "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê." + "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê.", + "suggest": "Dubare dibe? Ji piştgiriyê re binivîse" } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index b6995b8c..20aed06f 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -3003,6 +3003,7 @@ "error_attachment": "Een screenshot kon niet worden geüpload. Probeer het opnieuw.", "error_attachment_type": "Alleen jpg-, png- of webp-afbeeldingen.", "error_attachment_size": "Elke afbeelding mag maximaal 5 MB zijn.", - "error_attachment_count": "Maximaal 5 screenshots per bericht." + "error_attachment_count": "Maximaal 5 screenshots per bericht.", + "suggest": "Blijft het gebeuren? Schrijf support" } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 93070a99..7e4d1f17 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -3003,6 +3003,7 @@ "error_attachment": "Não foi possível enviar uma captura. Tente de novo.", "error_attachment_type": "Só imagens jpg, png ou webp.", "error_attachment_size": "Cada imagem pode ter no máximo 5 MB.", - "error_attachment_count": "No máximo 5 capturas por mensagem." + "error_attachment_count": "No máximo 5 capturas por mensagem.", + "suggest": "Continua acontecendo? Fale com o suporte" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 9165b5d0..efe8a882 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -3004,6 +3004,7 @@ "error_attachment": "Не удалось загрузить скриншот. Попробуйте ещё раз.", "error_attachment_type": "Только изображения jpg, png или webp.", "error_attachment_size": "Каждое изображение — не больше 5 МБ.", - "error_attachment_count": "Не больше 5 скриншотов в сообщении." + "error_attachment_count": "Не больше 5 скриншотов в сообщении.", + "suggest": "Повторяется? Напишите в поддержку" } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 972fedcd..e2324ce5 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -3003,6 +3003,7 @@ "error_attachment": "Bir ekran görüntüsü yüklenemedi. Tekrar dene.", "error_attachment_type": "Yalnızca jpg, png veya webp görseller.", "error_attachment_size": "Her görsel en fazla 5 MB olmalı.", - "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü." + "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü.", + "suggest": "Tekrarlıyor mu? Desteğe yaz" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 7904ca9d..1716e906 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -3003,6 +3003,7 @@ "error_attachment": "Skrinshotni yuklab bo‘lmadi. Qaytadan urinib ko‘ring.", "error_attachment_type": "Faqat jpg, png yoki webp rasm.", "error_attachment_size": "Har bir rasm 5 MB dan oshmasin.", - "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot." + "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot.", + "suggest": "Takrorlanyaptimi? Support’ga yozing" } } diff --git a/assets/translations/yue.json b/assets/translations/yue.json index c2ea56c0..6ce1e7a4 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -3003,6 +3003,7 @@ "error_attachment": "截圖上載唔到,請再試一次。", "error_attachment_type": "只接受 jpg、png 或 webp 圖片。", "error_attachment_size": "每張圖唔可以大過 5 MB。", - "error_attachment_count": "每條訊息最多 5 張截圖。" + "error_attachment_count": "每條訊息最多 5 張截圖。", + "suggest": "成日都係咁?搵支援" } } diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 2d40161a..f7632385 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -68,6 +68,8 @@ import 'package:soplay/features/download/domain/usecases/get_downloads_usecase.d import 'package:soplay/features/download/presentation/download_messages.dart'; import 'package:soplay/features/download/presentation/widgets/offline_copy_banner.dart'; import 'package:showcaseview/showcaseview.dart'; +import 'package:soplay/features/support/presentation/widgets/support_suggestion.dart'; +import 'package:soplay/features/support/data/support_models.dart'; class DetailPage extends StatelessWidget { const DetailPage({super.key, required this.args}); @@ -222,6 +224,8 @@ class _DetailScaffold extends StatelessWidget { message: message.startsWith('catalogue.') ? message.tr() : message, + provider: provider, + content: contentUrl, onRetry: () => context.read().add( DetailLoad(contentUrl, provider: provider, hint: preview), ), @@ -2005,8 +2009,14 @@ class _ErrorView extends StatelessWidget { required this.onRetry, required this.onBack, this.onSolveCloudflare, + this.provider, + this.content, }); + /// For a support request about this title, if the viewer writes one. + final String? provider; + final String? content; + final String message; final VoidCallback onRetry; final VoidCallback onBack; @@ -2062,6 +2072,14 @@ class _ErrorView extends StatelessWidget { label: Text('cloudflare.solve'.tr()), ), ], + const SizedBox(height: 8), + SupportSuggestion( + screen: 'detail', + category: SupportCategory.content, + provider: provider, + content: content, + error: message, + ), const Spacer(), ], ), diff --git a/lib/features/home/presentation/widgets/home_state_views.dart b/lib/features/home/presentation/widgets/home_state_views.dart index 740a5c65..b776a32a 100644 --- a/lib/features/home/presentation/widgets/home_state_views.dart +++ b/lib/features/home/presentation/widgets/home_state_views.dart @@ -10,6 +10,8 @@ import 'package:soplay/features/home/presentation/bloc/home/home_bloc.dart'; import 'package:soplay/features/home/presentation/bloc/home/home_event.dart'; import 'package:soplay/features/home/presentation/widgets/home_banner.dart'; import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; +import 'package:soplay/features/support/presentation/widgets/support_suggestion.dart'; +import 'package:soplay/features/support/data/support_models.dart'; class HomeSkeleton extends StatelessWidget { const HomeSkeleton({super.key}); @@ -376,6 +378,13 @@ class HomeErrorView extends StatelessWidget { ), ), ], + const SizedBox(height: 8), + SupportSuggestion( + screen: 'home', + category: SupportCategory.content, + provider: getIt().getCurrentProvider(), + error: failure.detail ?? message, + ), ], ), ), diff --git a/lib/features/my_list/presentation/widgets/my_list_state_views.dart b/lib/features/my_list/presentation/widgets/my_list_state_views.dart index dc958e11..df66050b 100644 --- a/lib/features/my_list/presentation/widgets/my_list_state_views.dart +++ b/lib/features/my_list/presentation/widgets/my_list_state_views.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/support/presentation/widgets/support_suggestion.dart'; class MyListEmptyView extends StatelessWidget { const MyListEmptyView({super.key}); @@ -52,6 +53,7 @@ class MyListErrorView extends StatelessWidget { message: message, actionLabel: 'general.try_again'.tr(), onAction: onRetry, + footer: SupportSuggestion(screen: 'my_list', error: message), ); } } @@ -64,6 +66,7 @@ class MyListStateView extends StatelessWidget { required this.message, this.actionLabel, this.onAction, + this.footer, }); final IconData icon; @@ -72,6 +75,9 @@ class MyListStateView extends StatelessWidget { final String? actionLabel; final VoidCallback? onAction; + /// Under the action, on an error only: the way to support. + final Widget? footer; + @override Widget build(BuildContext context) { return Padding( @@ -125,6 +131,7 @@ class MyListStateView extends StatelessWidget { ), ), ], + if (footer != null) ...[const SizedBox(height: 8), footer!], ], ), ), diff --git a/lib/features/notifications/presentation/pages/notifications_page.dart b/lib/features/notifications/presentation/pages/notifications_page.dart index 9a43b92a..0d190acb 100644 --- a/lib/features/notifications/presentation/pages/notifications_page.dart +++ b/lib/features/notifications/presentation/pages/notifications_page.dart @@ -11,6 +11,7 @@ import 'package:soplay/features/notifications/domain/entities/notification_item. import 'package:soplay/features/notifications/presentation/bloc/notifications_bloc.dart'; import 'package:soplay/features/notifications/presentation/notification_routing.dart'; import 'package:soplay/features/tracker/presentation/widgets/release_widgets.dart'; +import 'package:soplay/features/support/presentation/widgets/support_suggestion.dart'; class NotificationsPage extends StatelessWidget { const NotificationsPage({super.key}); @@ -592,6 +593,7 @@ class _ErrorView extends StatelessWidget { style: TextStyle(color: AppColors.primary), ), ), + SupportSuggestion(screen: 'notifications', error: message), ], ), ); diff --git a/lib/features/search/presentation/widgets/search_state_views.dart b/lib/features/search/presentation/widgets/search_state_views.dart index 63181654..f414f425 100644 --- a/lib/features/search/presentation/widgets/search_state_views.dart +++ b/lib/features/search/presentation/widgets/search_state_views.dart @@ -12,6 +12,8 @@ import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; import 'package:soplay/features/search/presentation/widgets/search_landing.dart'; import 'package:soplay/features/search/presentation/widgets/search_result_card.dart'; import 'package:soplay/features/sources/domain/source_failure.dart'; +import 'package:soplay/features/support/presentation/widgets/support_suggestion.dart'; +import 'package:soplay/features/support/data/support_models.dart'; class SearchContentView extends StatelessWidget { const SearchContentView({ @@ -670,6 +672,12 @@ class _SearchErrorView extends StatelessWidget { onTap: onRetry, autofocus: true, ), + const SizedBox(height: 8), + SupportSuggestion( + screen: 'search', + category: SupportCategory.content, + error: message, + ), ], ), ); diff --git a/lib/features/shorts/presentation/widgets/shorts_state_views.dart b/lib/features/shorts/presentation/widgets/shorts_state_views.dart index 5f5c476d..fb9d4394 100644 --- a/lib/features/shorts/presentation/widgets/shorts_state_views.dart +++ b/lib/features/shorts/presentation/widgets/shorts_state_views.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/support/presentation/widgets/support_suggestion.dart'; class ShortsLoadingView extends StatelessWidget { const ShortsLoadingView({super.key}); @@ -75,6 +76,7 @@ class ShortsErrorView extends StatelessWidget { message: message, actionLabel: 'general.try_again'.tr(), onAction: onRetry, + footer: SupportSuggestion(screen: 'shorts', error: message), ); } } @@ -86,6 +88,7 @@ class _ShortsMessage extends StatelessWidget { required this.message, this.actionLabel, this.onAction, + this.footer, }); final IconData icon; @@ -94,6 +97,9 @@ class _ShortsMessage extends StatelessWidget { final String? actionLabel; final VoidCallback? onAction; + /// Under the action, on an error only: the way to support. + final Widget? footer; + @override Widget build(BuildContext context) { return ColoredBox( @@ -165,6 +171,7 @@ class _ShortsMessage extends StatelessWidget { ), ), ], + if (footer != null) ...[const SizedBox(height: 10), footer!], ], ), ), diff --git a/lib/features/support/data/support_diagnostics.dart b/lib/features/support/data/support_diagnostics.dart index ca467452..d17e596d 100644 --- a/lib/features/support/data/support_diagnostics.dart +++ b/lib/features/support/data/support_diagnostics.dart @@ -51,6 +51,10 @@ class SupportDiagnostics { put('provider', args?.provider); put('content', args?.content); put('screen', args?.screen); + final error = args?.error?.trim(); + if (error != null && error.isNotEmpty) { + put('error', error.length > 200 ? '${error.substring(0, 199)}…' : error); + } if (includeLog) { final log = PlayerLog.instance; if (log.lines.isNotEmpty) { diff --git a/lib/features/support/data/support_models.dart b/lib/features/support/data/support_models.dart index bd2f3bf8..97271dff 100644 --- a/lib/features/support/data/support_models.dart +++ b/lib/features/support/data/support_models.dart @@ -124,12 +124,16 @@ class SupportRequestArgs { this.provider, this.content, this.screen, + this.error, }); final SupportCategory? category; final String? provider; final String? content; final String? screen; + + /// The error that was on screen when the viewer chose to write. + final String? error; } /// A refusal worth showing as it is: the server writes these for people. diff --git a/lib/features/support/presentation/widgets/support_suggestion.dart b/lib/features/support/presentation/widgets/support_suggestion.dart new file mode 100644 index 00000000..26af669f --- /dev/null +++ b/lib/features/support/presentation/widgets/support_suggestion.dart @@ -0,0 +1,46 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/support/data/support_models.dart'; + +/// "Keeps happening? Write to support" under an error. +/// +/// Opens a request already on the right category, naming the screen, the +/// source and the error that was on it, so the viewer only has to say what +/// they were doing. Quiet on purpose: it sits under Retry, never instead of it. +class SupportSuggestion extends StatelessWidget { + const SupportSuggestion({ + super.key, + required this.screen, + this.category = SupportCategory.bug, + this.provider, + this.content, + this.error, + }); + + final String screen; + final SupportCategory category; + final String? provider; + final String? content; + final String? error; + + @override + Widget build(BuildContext context) { + return TextButton.icon( + onPressed: () => context.push( + '/support/new', + extra: SupportRequestArgs( + category: category, + screen: screen, + provider: provider, + content: content, + error: error, + ), + ), + style: TextButton.styleFrom(foregroundColor: AppColors.textSecondary), + icon: const Icon(Icons.support_agent_rounded, size: 17), + label: Text('support.suggest'.tr(), style: const TextStyle(fontSize: 13)), + ); + } +} diff --git a/test/features/support/support_suggestion_test.dart b/test/features/support/support_suggestion_test.dart new file mode 100644 index 00000000..8b20eb9e --- /dev/null +++ b/test/features/support/support_suggestion_test.dart @@ -0,0 +1,67 @@ +// "Keeps happening? Write to support" under an error opens a request that +// already knows where it came from. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:soplay/features/support/data/support_diagnostics.dart'; +import 'package:soplay/features/support/data/support_models.dart'; +import 'package:soplay/features/support/presentation/widgets/support_suggestion.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('it opens a request on the right category, screen and error', ( + tester, + ) async { + SupportRequestArgs? opened; + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold( + body: SupportSuggestion( + screen: 'home', + category: SupportCategory.content, + provider: 'anikai', + error: 'HTTP 503 from anikai', + ), + ), + ), + GoRoute( + path: '/support/new', + builder: (_, state) { + opened = state.extra as SupportRequestArgs?; + return const Scaffold(body: Text('NEW REQUEST')); + }, + ), + ], + ); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + expect(find.text('NEW REQUEST'), findsOneWidget); + expect(opened?.category, SupportCategory.content); + expect(opened?.screen, 'home'); + expect(opened?.provider, 'anikai'); + expect(opened?.error, 'HTTP 503 from anikai'); + }); + + test('the error reaches the diagnostics, shortened', () async { + PackageInfo.setMockInitialValues( + appName: 'Sozo', + packageName: 'com.soplay.sozo', + version: '3.2.0', + buildNumber: '8', + buildSignature: '', + ); + final d = await SupportDiagnostics.collect( + language: 'en', + includeLog: false, + args: SupportRequestArgs(screen: 'search', error: 'x' * 500), + ); + expect(d['screen'], 'search'); + expect(d['error']!.length, 200); + expect(d['error'], endsWith('…')); + }); +} From 570935bd33cdffdfa5709eb282bf9e2338d0ca28 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:53:50 +0500 Subject: [PATCH 7/7] Fit the home-screen widget preview inside its card border The poster tile overflowed by 2 px: the cells were sized from the card's outer width, leaving out its 1 px border on each side. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../presentation/pages/onboarding_widgets_page.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/features/onboarding/presentation/pages/onboarding_widgets_page.dart b/lib/features/onboarding/presentation/pages/onboarding_widgets_page.dart index 32ff76b9..27aed72b 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_widgets_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_widgets_page.dart @@ -312,7 +312,9 @@ class _HomeScreenState extends State<_HomeScreen> final width = math.min(box.maxWidth, 360.0); const pad = 14.0; const gap = 12.0; - final cell = (width - pad * 2 - gap) / 2; + // What is left inside the card's 1 px border and its padding. + final inner = width - 2 - pad * 2; + final cell = (inner - gap) / 2; // The same card as the rest of the setup — its surface, its // hairline border, no shadow — so the widgets are what stands out, // not a slab of their own. @@ -346,7 +348,7 @@ class _HomeScreenState extends State<_HomeScreen> _ring( on: widget.streakPlaced && !widget.continuePlaced, child: _ContinueMock( - width: width - pad * 2, + width: inner, posters: [poster(1), poster(2), poster(3)], ), ),