diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 901b8819..221247cf 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,58 @@ "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": "هذا الطلب لم يعد موجوداً.", + "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 لقطات شاشة كحد أقصى في الرسالة.", + "suggest": "تتكرر المشكلة؟ راسل الدعم" } } diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 450dc761..f97db42b 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -2952,5 +2952,58 @@ "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": "ئەم داواکارییە ئیتر بوونی نییە.", + "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 وێنەی شاشە.", + "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە" } } diff --git a/assets/translations/de.json b/assets/translations/de.json index a7548875..8cc3ee27 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,58 @@ "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.", + "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.", + "suggest": "Passiert das öfter? Schreib dem Support" } } diff --git a/assets/translations/en.json b/assets/translations/en.json index 75ecc0e6..b7828f39 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,58 @@ "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.", + "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.", + "suggest": "Keeps happening? Write to support" } } diff --git a/assets/translations/es.json b/assets/translations/es.json index 6c0b820f..e45868f1 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,58 @@ "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.", + "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.", + "suggest": "¿Sigue pasando? Escribe a soporte" } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 427a4c76..e1a4c3dd 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,58 @@ "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.", + "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.", + "suggest": "Ça se reproduit ? Écris au support" } } diff --git a/assets/translations/id.json b/assets/translations/id.json index e8af63a8..d3996d02 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,58 @@ "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.", + "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.", + "suggest": "Terus terjadi? Hubungi dukungan" } } diff --git a/assets/translations/ku.json b/assets/translations/ku.json index c148dc09..556dc6e5 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -2952,5 +2952,58 @@ "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.", + "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ê.", + "suggest": "Dubare dibe? Ji piştgiriyê re binivîse" } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 94d5c263..20aed06f 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,58 @@ "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.", + "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.", + "suggest": "Blijft het gebeuren? Schrijf support" } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 43dd20ad..7e4d1f17 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,58 @@ "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.", + "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.", + "suggest": "Continua acontecendo? Fale com o suporte" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 8d1b98f4..efe8a882 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,58 @@ "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": "Это обращение больше не существует.", + "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 скриншотов в сообщении.", + "suggest": "Повторяется? Напишите в поддержку" } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index fca60de6..e2324ce5 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,58 @@ "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.", + "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ü.", + "suggest": "Tekrarlıyor mu? Desteğe yaz" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index d2d53e27..1716e906 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,58 @@ "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.", + "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.", + "suggest": "Takrorlanyaptimi? Support’ga yozing" } } diff --git a/assets/translations/yue.json b/assets/translations/yue.json index ccbf1820..6ce1e7a4 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,58 @@ "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": "呢個查詢已經唔存在。", + "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 張截圖。", + "suggest": "成日都係咁?搵支援" } } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 0fb49ca4..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'; @@ -137,6 +138,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 +787,16 @@ Future configureDependencies() async { getIt.registerSingleton( ReportsRepositoryImpl(getIt()), ); + getIt.registerLazySingleton( + () => 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( AppUpdaterDataSource(dio: getIt()), ); 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/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/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/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/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/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, 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/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/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/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)], ), ), 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/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 new file mode 100644 index 00000000..d17e596d --- /dev/null +++ b/lib/features/support/data/support_diagnostics.dart @@ -0,0 +1,87 @@ +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); + 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) { + 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..97271dff --- /dev/null +++ b/lib/features/support/data/support_models.dart @@ -0,0 +1,148 @@ +/// 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, +); + +/// 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(), + ); +} + +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, + 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. +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..d292fd25 --- /dev/null +++ b/lib/features/support/data/support_repository.dart @@ -0,0 +1,205 @@ +import 'dart:io'; + +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; [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()}); + + 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, + List attachments = const [], + String? language, + }) => _guard(() async { + final token = await _token(); + 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, + if (attachments.isNotEmpty) 'attachments': attachments, + 'language': ?language, + 'pushToken': ?token, + }, + options: _options, + ); + return _ticket(res.data); + }); + + 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, + if (attachments.isNotEmpty) 'attachments': attachments, + 'language': ?language, + 'pushToken': ?token, + }, + 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_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 new file mode 100644 index 00000000..f92365ca --- /dev/null +++ b/lib/features/support/presentation/pages/support_new_page.dart @@ -0,0 +1,423 @@ +import 'dart:io'; + +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'; +import 'package:soplay/features/support/presentation/widgets/support_screenshots.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; + 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 || _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() { + 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 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( + context, + ).showSnackBar(SnackBar(content: Text('support.sent'.tr()))); + context.pushReplacement('/support/${ticket.id}'); + } on SupportException catch (e) { + if (mounted) { + setState(() { + _sending = false; + _progress = null; + _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, + ), + 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()), + _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), + ), + ], + 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(), + 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, + // 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( + '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..6b3c498f --- /dev/null +++ b/lib/features/support/presentation/pages/support_ticket_page.dart @@ -0,0 +1,444 @@ +import 'dart:io'; + +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'; +import 'package:soplay/features/support/presentation/widgets/support_screenshots.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; + 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() { + 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 (!_canSend) return; + setState(() => _sending = true); + try { + 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) { + 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: _canSend ? _send : null, + shots: _shots, + onRemoveShot: (i) => setState(() => _shots.removeAt(i)), + onAttach: + !_sending && + _shots.length < SupportRepository.maxAttachments + ? _pickShots + : 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, + ), + ), + ), + 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), + 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, + 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) { + 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: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (shots.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ScreenshotStrip( + files: shots, + height: 84, + onRemove: sending ? (_) {} : onRemoveShot, + ), + ), + 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, + ), + ), + 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/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/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/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..88f415c7 --- /dev/null +++ b/test/features/support/support_repository_test.dart @@ -0,0 +1,261 @@ +// 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; + 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, + 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({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 = { + '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', + ), + ), + ); + }, + ); + + 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); + }); + }); +} 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('…')); + }); +} 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'}, ); });