diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..6a15c211 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -3031,5 +3031,67 @@ "error_attachment_size": "يجب ألا يتجاوز حجم كل صورة 5 ميغابايت.", "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة.", "suggest": "تتكرر المشكلة؟ راسل الدعم" + }, + "progress": { + "title": "المستوى والخبرة", + "level": "المستوى", + "level_short": "مس {}", + "level_up": "ارتقاء مستوى", + "level_reached": "وصلت إلى المستوى {}", + "unlocked_new": "جديد في خزانتك", + "open_locker": "افتح الخزانة", + "unavailable": "المستويات غير متاحة بعد. عد قريبًا.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP حتى المستوى {}", + "to_next_short": "{} XP حتى المستوى التالي", + "watch_today": "مشاهدة اليوم: {} من {} XP", + "how_to_earn": "كيف تكسب XP", + "rule_watch": "المشاهدة: 1 XP لكل دقيقة، حتى 300 يوميًا", + "rule_comment": "تعليق: 5 XP، حتى 10 تعليقات يوميًا", + "rule_like": "إعجاب بتعليقك: 1 XP، حتى 50 يوميًا", + "rule_streak": "يوم سلسلة: 10 XP، وأكثر في أيام الإنجاز", + "rule_achievement": "إنجاز: من 50 إلى 400 XP حسب الدرجة", + "recent": "أحدث XP", + "reason_watch": "المشاهدة", + "reason_comment": "التعليقات", + "reason_like": "إعجابات مستلمة", + "reason_streak": "يوم سلسلة", + "reason_achievement": "إنجاز", + "reason_other": "XP", + "locker": "الخزانة", + "slot_frame": "إطارات الصورة", + "slot_title": "الألقاب", + "slot_color": "ألوان الاسم", + "equip": "اضغط للارتداء", + "equipped": "مرتدى", + "unlock_level": "المستوى {}", + "unlock_badge": "شارة: {}", + "frame_bronze": "برونزي", + "frame_silver": "فضي", + "frame_gold": "ذهبي", + "frame_neon": "نيون", + "frame_sakura": "ساكورا", + "frame_ember": "جمر", + "frame_flame": "لهب", + "frame_moon": "قمر", + "title_newcomer": "وافد جديد", + "title_regular": "معتاد", + "title_veteran": "مخضرم", + "title_legend": "أسطورة", + "title_binger": "مدمن المسلسلات", + "title_cinephile": "عاشق السينما", + "title_otaku": "أوتاكو", + "title_bookworm": "دودة كتب", + "title_night_owl": "بومة الليل", + "title_host": "مضيف الحفلات", + "color_sky": "سماوي", + "color_mint": "نعناعي", + "color_gold": "ذهبي", + "color_rose": "وردي", + "color_violet": "بنفسجي", + "color_crimson": "قرمزي", + "color_aurora": "شفق", + "color_ember": "جمر" } } diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..65eaa472 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "هەر وێنەیەک دەبێت 5 MB یان کەمتر بێت.", "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە.", "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە" + }, + "progress": { + "title": "ئاست و XP", + "level": "ئاست", + "level_short": "ئاست {}", + "level_up": "بەرزبوونەوەی ئاست", + "level_reached": "گەیشتیتە ئاستی {}", + "unlocked_new": "نوێ لە کەبینەتەکەتدا", + "open_locker": "کردنەوەی کەبینەت", + "unavailable": "ئاستەکان هێشتا بەردەست نین. دواتر سەیر بکەرەوە.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP بۆ ئاستی {}", + "to_next_short": "{} XP بۆ ئاستی داهاتوو", + "watch_today": "سەیرکردنی ئەمڕۆ: {} لە {} XP", + "how_to_earn": "چۆن XP بەدەست بهێنیت", + "rule_watch": "سەیرکردن: هەر خولەکێک 1 XP، تا 300 لە ڕۆژێکدا", + "rule_comment": "لێدوان: 5 XP، تا 10 لێدوان لە ڕۆژێکدا", + "rule_like": "لایک لەسەر لێدوانەکەت: 1 XP، تا 50 لە ڕۆژێکدا", + "rule_streak": "ڕۆژی زنجیرە: 10 XP، زیاتر لە ڕۆژە گرنگەکاندا", + "rule_achievement": "دەستکەوت: 50 تا 400 XP بەپێی پلە", + "recent": "دوایین XP", + "reason_watch": "سەیرکردن", + "reason_comment": "لێدوانەکان", + "reason_like": "لایکی وەرگیراو", + "reason_streak": "ڕۆژی زنجیرە", + "reason_achievement": "دەستکەوت", + "reason_other": "XP", + "locker": "کەبینەت", + "slot_frame": "چوارچێوەی وێنە", + "slot_title": "نازناوەکان", + "slot_color": "ڕەنگی ناو", + "equip": "بۆ لەبەرکردن دەست لێبدە", + "equipped": "لەبەرکراوە", + "unlock_level": "ئاستی {}", + "unlock_badge": "نیشانە: {}", + "frame_bronze": "برۆنز", + "frame_silver": "زیو", + "frame_gold": "زێڕ", + "frame_neon": "نیۆن", + "frame_sakura": "ساکورا", + "frame_ember": "پشکۆ", + "frame_flame": "بڵێسە", + "frame_moon": "مانگ", + "title_newcomer": "تازەهاتوو", + "title_regular": "هەمیشەیی", + "title_veteran": "بەتەمەن", + "title_legend": "ئەفسانە", + "title_binger": "سەیرکەری بێوچان", + "title_cinephile": "خولیای سینەما", + "title_otaku": "ئۆتاکۆ", + "title_bookworm": "کتێبخوێن", + "title_night_owl": "کوندەپەپووی شەو", + "title_host": "خانەخوێی ئاهەنگ", + "color_sky": "ئاسمانی", + "color_mint": "پونگ", + "color_gold": "زێڕین", + "color_rose": "گوڵی", + "color_violet": "مۆر", + "color_crimson": "سووری تۆخ", + "color_aurora": "ئۆرۆرا", + "color_ember": "پشکۆ" } } diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..b1df671b 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -3027,5 +3027,67 @@ "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" + }, + "progress": { + "title": "Level & XP", + "level": "Level", + "level_short": "Lv. {}", + "level_up": "Level-Aufstieg", + "level_reached": "Du hast Level {} erreicht", + "unlocked_new": "Neu in deinem Spind", + "open_locker": "Spind öffnen", + "unavailable": "Level sind noch nicht verfügbar. Schau bald wieder vorbei.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP bis Level {}", + "to_next_short": "{} XP bis zum nächsten Level", + "watch_today": "Heute geschaut: {} von {} XP", + "how_to_earn": "So bekommst du XP", + "rule_watch": "Schauen: 1 XP pro Minute, bis zu 300 am Tag", + "rule_comment": "Ein Kommentar: 5 XP, bis zu 10 Kommentare am Tag", + "rule_like": "Ein Like auf deinen Kommentar: 1 XP, bis zu 50 am Tag", + "rule_streak": "Ein Streak-Tag: 10 XP, mehr an Meilenstein-Tagen", + "rule_achievement": "Ein Erfolg: 50 bis 400 XP je nach Stufe", + "recent": "Letzte XP", + "reason_watch": "Schauen", + "reason_comment": "Kommentare", + "reason_like": "Erhaltene Likes", + "reason_streak": "Streak-Tag", + "reason_achievement": "Erfolg", + "reason_other": "XP", + "locker": "Spind", + "slot_frame": "Avatar-Rahmen", + "slot_title": "Titel", + "slot_color": "Namensfarben", + "equip": "Zum Ausrüsten tippen", + "equipped": "Ausgerüstet", + "unlock_level": "Level {}", + "unlock_badge": "Abzeichen: {}", + "frame_bronze": "Bronze", + "frame_silver": "Silber", + "frame_gold": "Gold", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Glut", + "frame_flame": "Flamme", + "frame_moon": "Mond", + "title_newcomer": "Neuling", + "title_regular": "Stammgast", + "title_veteran": "Veteran", + "title_legend": "Legende", + "title_binger": "Binge-Watcher", + "title_cinephile": "Cineast", + "title_otaku": "Otaku", + "title_bookworm": "Bücherwurm", + "title_night_owl": "Nachteule", + "title_host": "Party-Gastgeber", + "color_sky": "Himmel", + "color_mint": "Minze", + "color_gold": "Gold", + "color_rose": "Rose", + "color_violet": "Violett", + "color_crimson": "Karmesin", + "color_aurora": "Polarlicht", + "color_ember": "Glut" } } diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..d1a8a4e8 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -3027,5 +3027,67 @@ "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" + }, + "progress": { + "title": "Level & XP", + "level": "Level", + "level_short": "Lv {}", + "level_up": "Level up", + "level_reached": "You reached level {}", + "unlocked_new": "New in your locker", + "open_locker": "Open locker", + "unavailable": "Levels are not available yet. Check back soon.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP to level {}", + "to_next_short": "{} XP to the next level", + "watch_today": "Watching today: {} of {} XP", + "how_to_earn": "How to earn XP", + "rule_watch": "Watching: 1 XP a minute, up to 300 a day", + "rule_comment": "A comment: 5 XP, up to 10 comments a day", + "rule_like": "A like on your comment: 1 XP, up to 50 a day", + "rule_streak": "A streak day: 10 XP, more on milestone days", + "rule_achievement": "An achievement: 50 to 400 XP by tier", + "recent": "Recent XP", + "reason_watch": "Watching", + "reason_comment": "Comments", + "reason_like": "Likes received", + "reason_streak": "Streak day", + "reason_achievement": "Achievement", + "reason_other": "XP", + "locker": "Locker", + "slot_frame": "Avatar frames", + "slot_title": "Titles", + "slot_color": "Name colours", + "equip": "Tap to equip", + "equipped": "Equipped", + "unlock_level": "Level {}", + "unlock_badge": "Badge: {}", + "frame_bronze": "Bronze", + "frame_silver": "Silver", + "frame_gold": "Gold", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Ember", + "frame_flame": "Flame", + "frame_moon": "Moon", + "title_newcomer": "Newcomer", + "title_regular": "Regular", + "title_veteran": "Veteran", + "title_legend": "Legend", + "title_binger": "Binge-watcher", + "title_cinephile": "Cinephile", + "title_otaku": "Otaku", + "title_bookworm": "Bookworm", + "title_night_owl": "Night owl", + "title_host": "Party host", + "color_sky": "Sky", + "color_mint": "Mint", + "color_gold": "Gold", + "color_rose": "Rose", + "color_violet": "Violet", + "color_crimson": "Crimson", + "color_aurora": "Aurora", + "color_ember": "Ember" } } diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..755b64d3 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -3027,5 +3027,67 @@ "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" + }, + "progress": { + "title": "Nivel y XP", + "level": "Nivel", + "level_short": "Nv. {}", + "level_up": "Subida de nivel", + "level_reached": "Has alcanzado el nivel {}", + "unlocked_new": "Novedades en tu taquilla", + "open_locker": "Abrir taquilla", + "unavailable": "Los niveles aún no están disponibles. Vuelve pronto.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP para el nivel {}", + "to_next_short": "{} XP para el siguiente nivel", + "watch_today": "Viendo hoy: {} de {} XP", + "how_to_earn": "Cómo ganar XP", + "rule_watch": "Ver: 1 XP por minuto, hasta 300 al día", + "rule_comment": "Un comentario: 5 XP, hasta 10 comentarios al día", + "rule_like": "Un me gusta en tu comentario: 1 XP, hasta 50 al día", + "rule_streak": "Un día de racha: 10 XP, más en días hito", + "rule_achievement": "Un logro: de 50 a 400 XP según el nivel", + "recent": "XP reciente", + "reason_watch": "Viendo", + "reason_comment": "Comentarios", + "reason_like": "Me gusta recibidos", + "reason_streak": "Día de racha", + "reason_achievement": "Logro", + "reason_other": "XP", + "locker": "Taquilla", + "slot_frame": "Marcos de avatar", + "slot_title": "Títulos", + "slot_color": "Colores de nombre", + "equip": "Toca para equipar", + "equipped": "Equipado", + "unlock_level": "Nivel {}", + "unlock_badge": "Insignia: {}", + "frame_bronze": "Bronce", + "frame_silver": "Plata", + "frame_gold": "Oro", + "frame_neon": "Neón", + "frame_sakura": "Sakura", + "frame_ember": "Brasa", + "frame_flame": "Llama", + "frame_moon": "Luna", + "title_newcomer": "Recién llegado", + "title_regular": "Habitual", + "title_veteran": "Veterano", + "title_legend": "Leyenda", + "title_binger": "Maratonista", + "title_cinephile": "Cinéfilo", + "title_otaku": "Otaku", + "title_bookworm": "Ratón de biblioteca", + "title_night_owl": "Búho nocturno", + "title_host": "Anfitrión", + "color_sky": "Cielo", + "color_mint": "Menta", + "color_gold": "Oro", + "color_rose": "Rosa", + "color_violet": "Violeta", + "color_crimson": "Carmesí", + "color_aurora": "Aurora", + "color_ember": "Brasa" } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..f7e4bfcc 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -3027,5 +3027,67 @@ "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" + }, + "progress": { + "title": "Niveau et XP", + "level": "Niveau", + "level_short": "Niv. {}", + "level_up": "Niveau supérieur", + "level_reached": "Vous avez atteint le niveau {}", + "unlocked_new": "Nouveau dans votre casier", + "open_locker": "Ouvrir le casier", + "unavailable": "Les niveaux ne sont pas encore disponibles. Revenez bientôt.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP avant le niveau {}", + "to_next_short": "{} XP avant le niveau suivant", + "watch_today": "Visionnage aujourd’hui : {} sur {} XP", + "how_to_earn": "Comment gagner de l’XP", + "rule_watch": "Regarder : 1 XP par minute, jusqu’à 300 par jour", + "rule_comment": "Un commentaire : 5 XP, jusqu’à 10 commentaires par jour", + "rule_like": "Un j’aime sur votre commentaire : 1 XP, jusqu’à 50 par jour", + "rule_streak": "Un jour de série : 10 XP, plus les jours d’étape", + "rule_achievement": "Un succès : de 50 à 400 XP selon le palier", + "recent": "XP récente", + "reason_watch": "Visionnage", + "reason_comment": "Commentaires", + "reason_like": "J’aime reçus", + "reason_streak": "Jour de série", + "reason_achievement": "Succès", + "reason_other": "XP", + "locker": "Casier", + "slot_frame": "Cadres d’avatar", + "slot_title": "Titres", + "slot_color": "Couleurs du nom", + "equip": "Touchez pour équiper", + "equipped": "Équipé", + "unlock_level": "Niveau {}", + "unlock_badge": "Badge : {}", + "frame_bronze": "Bronze", + "frame_silver": "Argent", + "frame_gold": "Or", + "frame_neon": "Néon", + "frame_sakura": "Sakura", + "frame_ember": "Braise", + "frame_flame": "Flamme", + "frame_moon": "Lune", + "title_newcomer": "Nouveau venu", + "title_regular": "Habitué", + "title_veteran": "Vétéran", + "title_legend": "Légende", + "title_binger": "Binge-watcheur", + "title_cinephile": "Cinéphile", + "title_otaku": "Otaku", + "title_bookworm": "Rat de bibliothèque", + "title_night_owl": "Oiseau de nuit", + "title_host": "Hôte de soirée", + "color_sky": "Ciel", + "color_mint": "Menthe", + "color_gold": "Or", + "color_rose": "Rose", + "color_violet": "Violet", + "color_crimson": "Cramoisi", + "color_aurora": "Aurore", + "color_ember": "Braise" } } diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..bf31dc1a 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "हर इमेज 5 MB या उससे छोटी होनी चाहिए।", "error_attachment_count": "एक मैसेज में ज़्यादा से ज़्यादा 5 स्क्रीनशॉट।", "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें" + }, + "progress": { + "title": "लेवल और XP", + "level": "लेवल", + "level_short": "Lv {}", + "level_up": "लेवल अप", + "level_reached": "आप लेवल {} पर पहुँच गए", + "unlocked_new": "आपके लॉकर में नया", + "open_locker": "लॉकर खोलें", + "unavailable": "लेवल अभी उपलब्ध नहीं हैं। थोड़ी देर बाद देखें।", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP और, फिर लेवल {}", + "to_next_short": "अगले लेवल तक {} XP", + "watch_today": "आज देखा: {} / {} XP", + "how_to_earn": "XP कैसे कमाएँ", + "rule_watch": "देखना: हर मिनट 1 XP, दिन में 300 तक", + "rule_comment": "कमेंट: 5 XP, दिन में 10 कमेंट तक", + "rule_like": "आपके कमेंट पर लाइक: 1 XP, दिन में 50 तक", + "rule_streak": "स्ट्रीक का दिन: 10 XP, माइलस्टोन वाले दिन ज़्यादा", + "rule_achievement": "अचीवमेंट: टियर के हिसाब से 50 से 400 XP", + "recent": "हाल के XP", + "reason_watch": "देखना", + "reason_comment": "कमेंट", + "reason_like": "मिले लाइक", + "reason_streak": "स्ट्रीक का दिन", + "reason_achievement": "अचीवमेंट", + "reason_other": "XP", + "locker": "लॉकर", + "slot_frame": "अवतार फ़्रेम", + "slot_title": "टाइटल", + "slot_color": "नाम के रंग", + "equip": "पहनने के लिए टैप करें", + "equipped": "पहना हुआ", + "unlock_level": "लेवल {}", + "unlock_badge": "बैज: {}", + "frame_bronze": "कांस्य", + "frame_silver": "चाँदी", + "frame_gold": "सोना", + "frame_neon": "नियॉन", + "frame_sakura": "साकुरा", + "frame_ember": "अंगारा", + "frame_flame": "ज्वाला", + "frame_moon": "चाँद", + "title_newcomer": "नवागंतुक", + "title_regular": "नियमित", + "title_veteran": "अनुभवी", + "title_legend": "लीजेंड", + "title_binger": "बिंज-वॉचर", + "title_cinephile": "सिनेप्रेमी", + "title_otaku": "ओटाकू", + "title_bookworm": "किताबी कीड़ा", + "title_night_owl": "रात का उल्लू", + "title_host": "पार्टी होस्ट", + "color_sky": "आसमानी", + "color_mint": "पुदीना", + "color_gold": "सुनहरा", + "color_rose": "गुलाबी", + "color_violet": "बैंगनी", + "color_crimson": "गहरा लाल", + "color_aurora": "ऑरोरा", + "color_ember": "अंगारा" } } diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..e6da719b 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Setiap gambar maksimal 5 MB.", "error_attachment_count": "Maksimal 5 tangkapan layar per pesan.", "suggest": "Terus terjadi? Hubungi dukungan" + }, + "progress": { + "title": "Level & XP", + "level": "Level", + "level_short": "Lv {}", + "level_up": "Naik level", + "level_reached": "Kamu mencapai level {}", + "unlocked_new": "Baru di lokermu", + "open_locker": "Buka loker", + "unavailable": "Level belum tersedia. Cek lagi nanti.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP menuju level {}", + "to_next_short": "{} XP menuju level berikutnya", + "watch_today": "Menonton hari ini: {} dari {} XP", + "how_to_earn": "Cara mendapatkan XP", + "rule_watch": "Menonton: 1 XP per menit, hingga 300 per hari", + "rule_comment": "Komentar: 5 XP, hingga 10 komentar per hari", + "rule_like": "Suka pada komentarmu: 1 XP, hingga 50 per hari", + "rule_streak": "Hari beruntun: 10 XP, lebih banyak di hari pencapaian", + "rule_achievement": "Pencapaian: 50 hingga 400 XP sesuai tingkat", + "recent": "XP terbaru", + "reason_watch": "Menonton", + "reason_comment": "Komentar", + "reason_like": "Suka diterima", + "reason_streak": "Hari beruntun", + "reason_achievement": "Pencapaian", + "reason_other": "XP", + "locker": "Loker", + "slot_frame": "Bingkai avatar", + "slot_title": "Gelar", + "slot_color": "Warna nama", + "equip": "Ketuk untuk memakai", + "equipped": "Dipakai", + "unlock_level": "Level {}", + "unlock_badge": "Lencana: {}", + "frame_bronze": "Perunggu", + "frame_silver": "Perak", + "frame_gold": "Emas", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Bara", + "frame_flame": "Api", + "frame_moon": "Bulan", + "title_newcomer": "Pendatang baru", + "title_regular": "Pelanggan tetap", + "title_veteran": "Veteran", + "title_legend": "Legenda", + "title_binger": "Maraton tontonan", + "title_cinephile": "Pencinta film", + "title_otaku": "Otaku", + "title_bookworm": "Kutu buku", + "title_night_owl": "Burung hantu malam", + "title_host": "Tuan rumah pesta", + "color_sky": "Langit", + "color_mint": "Mint", + "color_gold": "Emas", + "color_rose": "Mawar", + "color_violet": "Ungu", + "color_crimson": "Merah tua", + "color_aurora": "Aurora", + "color_ember": "Bara" } } diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..2e46aba2 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -3027,5 +3027,67 @@ "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" + }, + "progress": { + "title": "Ast û XP", + "level": "Ast", + "level_short": "Ast {}", + "level_up": "Ast bilind bû", + "level_reached": "Tu gihîştî asta {}", + "unlocked_new": "Nû di dolaba te de", + "open_locker": "Dolabê veke", + "unavailable": "Ast hêj ne amade ne. Paşê dîsa binêre.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP heta asta {}", + "to_next_short": "Heta asta din {} XP", + "watch_today": "Temaşeya îro: {} ji {} XP", + "how_to_earn": "Meriv çawa XP qezenc dike", + "rule_watch": "Temaşe: her deqîqe 1 XP, rojê heta 300", + "rule_comment": "Şîrove: 5 XP, rojê heta 10 şîroveyan", + "rule_like": "Ecibandinek li şîroveya te: 1 XP, rojê heta 50", + "rule_streak": "Roja rêzê: 10 XP, di rojên girîng de bêtir", + "rule_achievement": "Serkeftin: li gorî astê ji 50 heta 400 XP", + "recent": "XP-ya dawî", + "reason_watch": "Temaşe", + "reason_comment": "Şîrove", + "reason_like": "Ecibandinên hatine", + "reason_streak": "Roja rêzê", + "reason_achievement": "Serkeftin", + "reason_other": "XP", + "locker": "Dolab", + "slot_frame": "Çarçoveyên avatarê", + "slot_title": "Nasnav", + "slot_color": "Rengên navî", + "equip": "Ji bo lixwekirinê bitikîne", + "equipped": "Li xwe kirî", + "unlock_level": "Ast {}", + "unlock_badge": "Nîşan: {}", + "frame_bronze": "Bronz", + "frame_silver": "Zîv", + "frame_gold": "Zêr", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Pelû", + "frame_flame": "Agir", + "frame_moon": "Heyv", + "title_newcomer": "Nûhatî", + "title_regular": "Domdar", + "title_veteran": "Kevnar", + "title_legend": "Efsane", + "title_binger": "Temaşevanê bêrawestan", + "title_cinephile": "Evîndarê sînemayê", + "title_otaku": "Otaku", + "title_bookworm": "Pirtûkxwîn", + "title_night_owl": "Kundê şevê", + "title_host": "Mazûvanê şahiyê", + "color_sky": "Asîmanî", + "color_mint": "Pûng", + "color_gold": "Zêrîn", + "color_rose": "Gulî", + "color_violet": "Mor", + "color_crimson": "Sorê tarî", + "color_aurora": "Ronahiya bakur", + "color_ember": "Pelû" } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..af5b3875 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Elke afbeelding mag maximaal 5 MB zijn.", "error_attachment_count": "Maximaal 5 screenshots per bericht.", "suggest": "Blijft het gebeuren? Schrijf support" + }, + "progress": { + "title": "Level en XP", + "level": "Level", + "level_short": "Lv {}", + "level_up": "Level omhoog", + "level_reached": "Je hebt level {} bereikt", + "unlocked_new": "Nieuw in je kluisje", + "open_locker": "Kluisje openen", + "unavailable": "Levels zijn nog niet beschikbaar. Kom binnenkort terug.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP tot level {}", + "to_next_short": "{} XP tot het volgende level", + "watch_today": "Vandaag gekeken: {} van {} XP", + "how_to_earn": "Zo verdien je XP", + "rule_watch": "Kijken: 1 XP per minuut, tot 300 per dag", + "rule_comment": "Een reactie: 5 XP, tot 10 reacties per dag", + "rule_like": "Een like op je reactie: 1 XP, tot 50 per dag", + "rule_streak": "Een streakdag: 10 XP, meer op mijlpaaldagen", + "rule_achievement": "Een prestatie: 50 tot 400 XP per niveau", + "recent": "Recente XP", + "reason_watch": "Kijken", + "reason_comment": "Reacties", + "reason_like": "Ontvangen likes", + "reason_streak": "Streakdag", + "reason_achievement": "Prestatie", + "reason_other": "XP", + "locker": "Kluisje", + "slot_frame": "Avatarranden", + "slot_title": "Titels", + "slot_color": "Naamkleuren", + "equip": "Tik om te dragen", + "equipped": "Gedragen", + "unlock_level": "Level {}", + "unlock_badge": "Badge: {}", + "frame_bronze": "Brons", + "frame_silver": "Zilver", + "frame_gold": "Goud", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Gloed", + "frame_flame": "Vlam", + "frame_moon": "Maan", + "title_newcomer": "Nieuwkomer", + "title_regular": "Vaste gast", + "title_veteran": "Veteraan", + "title_legend": "Legende", + "title_binger": "Bingewatcher", + "title_cinephile": "Filmliefhebber", + "title_otaku": "Otaku", + "title_bookworm": "Boekenwurm", + "title_night_owl": "Nachtuil", + "title_host": "Feestgastheer", + "color_sky": "Lucht", + "color_mint": "Munt", + "color_gold": "Goud", + "color_rose": "Roos", + "color_violet": "Violet", + "color_crimson": "Karmozijn", + "color_aurora": "Poollicht", + "color_ember": "Gloed" } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..b7c337ec 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -3027,5 +3027,67 @@ "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" + }, + "progress": { + "title": "Nível e XP", + "level": "Nível", + "level_short": "Nv. {}", + "level_up": "Subiu de nível", + "level_reached": "Você chegou ao nível {}", + "unlocked_new": "Novidades no seu armário", + "open_locker": "Abrir armário", + "unavailable": "Os níveis ainda não estão disponíveis. Volte em breve.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP até o nível {}", + "to_next_short": "{} XP até o próximo nível", + "watch_today": "Assistido hoje: {} de {} XP", + "how_to_earn": "Como ganhar XP", + "rule_watch": "Assistir: 1 XP por minuto, até 300 por dia", + "rule_comment": "Um comentário: 5 XP, até 10 comentários por dia", + "rule_like": "Uma curtida no seu comentário: 1 XP, até 50 por dia", + "rule_streak": "Um dia de sequência: 10 XP, mais em dias marcantes", + "rule_achievement": "Uma conquista: de 50 a 400 XP conforme o nível", + "recent": "XP recente", + "reason_watch": "Assistindo", + "reason_comment": "Comentários", + "reason_like": "Curtidas recebidas", + "reason_streak": "Dia de sequência", + "reason_achievement": "Conquista", + "reason_other": "XP", + "locker": "Armário", + "slot_frame": "Molduras de avatar", + "slot_title": "Títulos", + "slot_color": "Cores do nome", + "equip": "Toque para equipar", + "equipped": "Equipado", + "unlock_level": "Nível {}", + "unlock_badge": "Medalha: {}", + "frame_bronze": "Bronze", + "frame_silver": "Prata", + "frame_gold": "Ouro", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Brasa", + "frame_flame": "Chama", + "frame_moon": "Lua", + "title_newcomer": "Novato", + "title_regular": "Frequentador", + "title_veteran": "Veterano", + "title_legend": "Lenda", + "title_binger": "Maratonista", + "title_cinephile": "Cinéfilo", + "title_otaku": "Otaku", + "title_bookworm": "Rato de biblioteca", + "title_night_owl": "Coruja noturna", + "title_host": "Anfitrião", + "color_sky": "Céu", + "color_mint": "Menta", + "color_gold": "Ouro", + "color_rose": "Rosa", + "color_violet": "Violeta", + "color_crimson": "Carmesim", + "color_aurora": "Aurora", + "color_ember": "Brasa" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..98253a90 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -3028,5 +3028,67 @@ "error_attachment_size": "Каждое изображение — не больше 5 МБ.", "error_attachment_count": "Не больше 5 скриншотов в сообщении.", "suggest": "Повторяется? Напишите в поддержку" + }, + "progress": { + "title": "Уровень и XP", + "level": "Уровень", + "level_short": "Ур. {}", + "level_up": "Новый уровень", + "level_reached": "Вы достигли уровня {}", + "unlocked_new": "Новое в вашем шкафчике", + "open_locker": "Открыть шкафчик", + "unavailable": "Уровни пока недоступны. Загляните позже.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP до уровня {}", + "to_next_short": "{} XP до следующего уровня", + "watch_today": "Просмотр сегодня: {} из {} XP", + "how_to_earn": "Как получить XP", + "rule_watch": "Просмотр: 1 XP в минуту, до 300 в день", + "rule_comment": "Комментарий: 5 XP, до 10 комментариев в день", + "rule_like": "Лайк на ваш комментарий: 1 XP, до 50 в день", + "rule_streak": "День серии: 10 XP, больше в юбилейные дни", + "rule_achievement": "Достижение: от 50 до 400 XP по уровню", + "recent": "Недавние XP", + "reason_watch": "Просмотр", + "reason_comment": "Комментарии", + "reason_like": "Полученные лайки", + "reason_streak": "День серии", + "reason_achievement": "Достижение", + "reason_other": "XP", + "locker": "Шкафчик", + "slot_frame": "Рамки аватара", + "slot_title": "Титулы", + "slot_color": "Цвета имени", + "equip": "Нажмите, чтобы надеть", + "equipped": "Надето", + "unlock_level": "Уровень {}", + "unlock_badge": "Значок: {}", + "frame_bronze": "Бронза", + "frame_silver": "Серебро", + "frame_gold": "Золото", + "frame_neon": "Неон", + "frame_sakura": "Сакура", + "frame_ember": "Угли", + "frame_flame": "Пламя", + "frame_moon": "Луна", + "title_newcomer": "Новичок", + "title_regular": "Завсегдатай", + "title_veteran": "Ветеран", + "title_legend": "Легенда", + "title_binger": "Запойный зритель", + "title_cinephile": "Киноман", + "title_otaku": "Отаку", + "title_bookworm": "Книжный червь", + "title_night_owl": "Ночная сова", + "title_host": "Хозяин вечеринок", + "color_sky": "Небо", + "color_mint": "Мята", + "color_gold": "Золото", + "color_rose": "Роза", + "color_violet": "Фиалка", + "color_crimson": "Багрянец", + "color_aurora": "Сияние", + "color_ember": "Угли" } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..657b9b31 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -3027,5 +3027,67 @@ "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" + }, + "progress": { + "title": "Seviye ve XP", + "level": "Seviye", + "level_short": "Sv. {}", + "level_up": "Seviye atladın", + "level_reached": "{}. seviyeye ulaştın", + "unlocked_new": "Dolabında yeni", + "open_locker": "Dolabı aç", + "unavailable": "Seviyeler henüz kullanılamıyor. Yakında tekrar bak.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP sonra {}. seviye", + "to_next_short": "Sonraki seviyeye {} XP", + "watch_today": "Bugün izleme: {} / {} XP", + "how_to_earn": "XP nasıl kazanılır", + "rule_watch": "İzlemek: dakikada 1 XP, günde en fazla 300", + "rule_comment": "Yorum: 5 XP, günde en fazla 10 yorum", + "rule_like": "Yorumuna beğeni: 1 XP, günde en fazla 50", + "rule_streak": "Seri günü: 10 XP, dönüm noktası günlerinde daha fazla", + "rule_achievement": "Başarım: kademeye göre 50 ile 400 XP", + "recent": "Son XP", + "reason_watch": "İzleme", + "reason_comment": "Yorumlar", + "reason_like": "Alınan beğeniler", + "reason_streak": "Seri günü", + "reason_achievement": "Başarım", + "reason_other": "XP", + "locker": "Dolap", + "slot_frame": "Avatar çerçeveleri", + "slot_title": "Unvanlar", + "slot_color": "İsim renkleri", + "equip": "Kuşanmak için dokun", + "equipped": "Kuşanıldı", + "unlock_level": "Seviye {}", + "unlock_badge": "Rozet: {}", + "frame_bronze": "Bronz", + "frame_silver": "Gümüş", + "frame_gold": "Altın", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Kor", + "frame_flame": "Alev", + "frame_moon": "Ay", + "title_newcomer": "Yeni gelen", + "title_regular": "Müdavim", + "title_veteran": "Kıdemli", + "title_legend": "Efsane", + "title_binger": "Dizi kurdu", + "title_cinephile": "Sinemasever", + "title_otaku": "Otaku", + "title_bookworm": "Kitap kurdu", + "title_night_owl": "Gece kuşu", + "title_host": "Parti ev sahibi", + "color_sky": "Gök", + "color_mint": "Nane", + "color_gold": "Altın", + "color_rose": "Gül", + "color_violet": "Menekşe", + "color_crimson": "Kızıl", + "color_aurora": "Kutup ışığı", + "color_ember": "Kor" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..6ea9c4e9 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -3027,5 +3027,67 @@ "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" + }, + "progress": { + "title": "Daraja va XP", + "level": "Daraja", + "level_short": "{}-dar.", + "level_up": "Yangi daraja", + "level_reached": "Siz {}-darajaga yetdingiz", + "unlocked_new": "Shkafingizda yangilik", + "open_locker": "Shkafni ochish", + "unavailable": "Darajalar hali mavjud emas. Keyinroq qarab ko‘ring.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP qoldi, keyin {}-daraja", + "to_next_short": "Keyingi darajagacha {} XP", + "watch_today": "Bugun tomosha: {} / {} XP", + "how_to_earn": "XP qanday olinadi", + "rule_watch": "Tomosha: daqiqasiga 1 XP, kuniga 300 tagacha", + "rule_comment": "Izoh: 5 XP, kuniga 10 ta izohgacha", + "rule_like": "Izohingizga layk: 1 XP, kuniga 50 tagacha", + "rule_streak": "Streak kuni: 10 XP, muhim kunlarda ko‘proq", + "rule_achievement": "Yutuq: darajasiga qarab 50 dan 400 XP gacha", + "recent": "So‘nggi XP", + "reason_watch": "Tomosha", + "reason_comment": "Izohlar", + "reason_like": "Olingan layklar", + "reason_streak": "Streak kuni", + "reason_achievement": "Yutuq", + "reason_other": "XP", + "locker": "Shkaf", + "slot_frame": "Avatar ramkalari", + "slot_title": "Unvonlar", + "slot_color": "Ism ranglari", + "equip": "Kiyish uchun bosing", + "equipped": "Kiyilgan", + "unlock_level": "{}-daraja", + "unlock_badge": "Nishon: {}", + "frame_bronze": "Bronza", + "frame_silver": "Kumush", + "frame_gold": "Oltin", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Cho‘g‘", + "frame_flame": "Alanga", + "frame_moon": "Oy", + "title_newcomer": "Yangi kelgan", + "title_regular": "Doimiy", + "title_veteran": "Faxriy", + "title_legend": "Afsona", + "title_binger": "Serial ishqibozi", + "title_cinephile": "Kinoshunos", + "title_otaku": "Otaku", + "title_bookworm": "Kitobxon", + "title_night_owl": "Tungi boyqush", + "title_host": "Bazm egasi", + "color_sky": "Osmon", + "color_mint": "Yalpiz", + "color_gold": "Oltin", + "color_rose": "Atirgul", + "color_violet": "Binafsha", + "color_crimson": "Qirmizi", + "color_aurora": "Qutb shafag‘i", + "color_ember": "Cho‘g‘" } } diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..c78127a9 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Mỗi ảnh tối đa 5 MB.", "error_attachment_count": "Tối đa 5 ảnh mỗi tin nhắn.", "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ" + }, + "progress": { + "title": "Cấp độ & XP", + "level": "Cấp", + "level_short": "Cấp {}", + "level_up": "Lên cấp", + "level_reached": "Bạn đã đạt cấp {}", + "unlocked_new": "Mới trong tủ đồ của bạn", + "open_locker": "Mở tủ đồ", + "unavailable": "Cấp độ chưa khả dụng. Hãy quay lại sau.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "Còn {} XP lên cấp {}", + "to_next_short": "Còn {} XP lên cấp tiếp theo", + "watch_today": "Xem hôm nay: {} / {} XP", + "how_to_earn": "Cách nhận XP", + "rule_watch": "Xem: 1 XP mỗi phút, tối đa 300 mỗi ngày", + "rule_comment": "Bình luận: 5 XP, tối đa 10 bình luận mỗi ngày", + "rule_like": "Lượt thích bình luận của bạn: 1 XP, tối đa 50 mỗi ngày", + "rule_streak": "Ngày chuỗi: 10 XP, nhiều hơn vào ngày cột mốc", + "rule_achievement": "Thành tích: 50 đến 400 XP theo bậc", + "recent": "XP gần đây", + "reason_watch": "Xem phim", + "reason_comment": "Bình luận", + "reason_like": "Lượt thích nhận được", + "reason_streak": "Ngày chuỗi", + "reason_achievement": "Thành tích", + "reason_other": "XP", + "locker": "Tủ đồ", + "slot_frame": "Khung ảnh đại diện", + "slot_title": "Danh hiệu", + "slot_color": "Màu tên", + "equip": "Chạm để trang bị", + "equipped": "Đã trang bị", + "unlock_level": "Cấp {}", + "unlock_badge": "Huy hiệu: {}", + "frame_bronze": "Đồng", + "frame_silver": "Bạc", + "frame_gold": "Vàng", + "frame_neon": "Neon", + "frame_sakura": "Hoa anh đào", + "frame_ember": "Than hồng", + "frame_flame": "Ngọn lửa", + "frame_moon": "Mặt trăng", + "title_newcomer": "Người mới", + "title_regular": "Khách quen", + "title_veteran": "Kỳ cựu", + "title_legend": "Huyền thoại", + "title_binger": "Mọt phim bộ", + "title_cinephile": "Người mê điện ảnh", + "title_otaku": "Otaku", + "title_bookworm": "Mọt sách", + "title_night_owl": "Cú đêm", + "title_host": "Chủ tiệc", + "color_sky": "Bầu trời", + "color_mint": "Bạc hà", + "color_gold": "Vàng", + "color_rose": "Hồng", + "color_violet": "Tím", + "color_crimson": "Đỏ thẫm", + "color_aurora": "Cực quang", + "color_ember": "Than hồng" } } diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..71fa0083 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "每張圖唔可以大過 5 MB。", "error_attachment_count": "每條訊息最多 5 張截圖。", "suggest": "成日都係咁?搵支援" + }, + "progress": { + "title": "等級同 XP", + "level": "等級", + "level_short": "Lv {}", + "level_up": "升級", + "level_reached": "你升到第 {} 級", + "unlocked_new": "儲物櫃有新嘢", + "open_locker": "打開儲物櫃", + "unavailable": "等級暫時未有,遲啲再睇吓。", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "仲差 {} XP 到第 {} 級", + "to_next_short": "仲差 {} XP 到下一級", + "watch_today": "今日睇咗:{} / {} XP", + "how_to_earn": "點樣賺 XP", + "rule_watch": "睇片:每分鐘 1 XP,每日最多 300", + "rule_comment": "留言:5 XP,每日最多計 10 個留言", + "rule_like": "你嘅留言有人讚:1 XP,每日最多 50", + "rule_streak": "連續日:10 XP,里程碑嗰日仲多", + "rule_achievement": "成就:按等級 50 至 400 XP", + "recent": "最近 XP", + "reason_watch": "睇片", + "reason_comment": "留言", + "reason_like": "收到嘅讚", + "reason_streak": "連續日", + "reason_achievement": "成就", + "reason_other": "XP", + "locker": "儲物櫃", + "slot_frame": "頭像框", + "slot_title": "稱號", + "slot_color": "名字顏色", + "equip": "撳一下裝備", + "equipped": "已裝備", + "unlock_level": "第 {} 級", + "unlock_badge": "徽章:{}", + "frame_bronze": "銅", + "frame_silver": "銀", + "frame_gold": "金", + "frame_neon": "霓虹", + "frame_sakura": "櫻花", + "frame_ember": "餘燼", + "frame_flame": "火焰", + "frame_moon": "月亮", + "title_newcomer": "新人", + "title_regular": "熟客", + "title_veteran": "老鳥", + "title_legend": "傳奇", + "title_binger": "追劇狂", + "title_cinephile": "電影迷", + "title_otaku": "御宅族", + "title_bookworm": "書蟲", + "title_night_owl": "夜貓", + "title_host": "派對主人", + "color_sky": "天藍", + "color_mint": "薄荷", + "color_gold": "金色", + "color_rose": "玫瑰", + "color_violet": "紫羅蘭", + "color_crimson": "深紅", + "color_aurora": "極光", + "color_ember": "餘燼" } } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index f2f65fee..b2d65ba9 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -246,6 +246,9 @@ import 'package:soplay/features/social/data/social_service.dart'; import 'package:soplay/features/achievements/data/achievements_remote_data_source.dart'; import 'package:soplay/features/achievements/data/achievements_service.dart'; import 'package:soplay/features/achievements/domain/achievements.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; import 'package:soplay/features/home_widget/home_widget_sync.dart'; final getIt = GetIt.instance; @@ -397,6 +400,17 @@ Future configureDependencies() async { hive: getIt(), ), ); + final progressRemote = ProgressRemoteDataSource(dio: getIt()); + getIt.registerSingleton( + ProgressService(remote: progressRemote, hive: getIt()), + ); + getIt.registerSingleton( + WatchTimeUploader( + remote: progressRemote, + hive: getIt(), + progress: getIt(), + )..start(), + ); getIt.registerSingleton( HistorySyncService( remote: getIt(), @@ -415,6 +429,7 @@ Future configureDependencies() async { // land in the profile it was watched in, before the sync settles. beforeSwitch: () async { await PlayerDock.instance.close(); + await getIt().flush(); await getIt().settle(); }, onScopeChanged: _onProfileScopeChanged, @@ -1251,6 +1266,7 @@ Future _onProfileScopeChanged({required bool resync}) async { getIt().revision.value++; getIt().onProfileChanged(); getIt().reload(); + getIt().reload(); if (!resync || !hive.isLoggedIn) return; // Push goes to whichever profile registered last, so every switch // re-registers under the new X-Sozo-Profile. diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e3b68b08..6ff57e9d 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -110,6 +110,7 @@ import '../../features/home/presentation/pages/home_view_all_page.dart'; import '../../features/home/presentation/pages/genres_page.dart'; import '../../features/search/domain/entities/genre_entity.dart'; import 'package:soplay/features/achievements/presentation/pages/achievements_page.dart'; +import 'package:soplay/features/progress/presentation/pages/progress_page.dart'; /// The arrival every "opening a thing" route shares. /// @@ -443,6 +444,10 @@ class AppRouter { builder: (context, state) => const NotificationsPage(), ), GoRoute(path: '/streak', builder: (context, state) => const StreakPage()), + GoRoute( + path: '/progress', + builder: (context, state) => const ProgressPage(), + ), GoRoute( path: '/achievements', builder: (context, state) => const AchievementsPage(), diff --git a/lib/core/storage/profile_scope.dart b/lib/core/storage/profile_scope.dart index 386b2a68..dc68c2b4 100644 --- a/lib/core/storage/profile_scope.dart +++ b/lib/core/storage/profile_scope.dart @@ -66,6 +66,9 @@ class ProfileScope { 'follow_sync_outbox', 'follow_sync_tombstones', 'follow_sync_at', + 'progress_view', + 'progress_seen_level', + 'progress_watch_sent', }; static String box(String base) => boxFor(base, _namespace); diff --git a/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart b/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart index 6be659a7..c86d0587 100644 --- a/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart +++ b/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart @@ -6,6 +6,8 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/router/app_router.dart'; import 'package:soplay/features/achievements/data/achievements_service.dart'; import 'package:soplay/features/achievements/presentation/dialogs/achievement_unlocked_dialog.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/presentation/dialogs/level_up_dialog.dart'; /// Shows badges as they are earned — but never over something being watched /// or read. @@ -37,12 +39,17 @@ class AchievementCelebrationHost extends StatefulWidget { class _AchievementCelebrationHostState extends State { AchievementsService? _service; + ProgressService? _progress; bool _showing = false; Timer? _settle; @override void initState() { super.initState(); + if (getIt.isRegistered()) { + _progress = getIt() + ..pendingLevelUp.addListener(_schedule); + } if (!getIt.isRegistered()) return; _service = getIt()..pending.addListener(_schedule); AppRouter.router.routerDelegate.addListener(_schedule); @@ -52,6 +59,7 @@ class _AchievementCelebrationHostState void dispose() { _settle?.cancel(); _service?.pending.removeListener(_schedule); + _progress?.pendingLevelUp.removeListener(_schedule); AppRouter.router.routerDelegate.removeListener(_schedule); super.dispose(); } @@ -66,7 +74,8 @@ class _AchievementCelebrationHostState Future _maybeShow() async { final service = _service; if (service == null || _showing || !mounted) return; - if (service.pending.value.isEmpty) return; + final levelUp = _progress?.pendingLevelUp.value; + if (service.pending.value.isEmpty && levelUp == null) return; final path = AppRouter.router.routerDelegate.currentConfiguration.uri.path; if (AchievementCelebrationHost.quietPaths.contains(path)) return; final context = AppRouter.router.routerDelegate.navigatorKey.currentContext; @@ -82,17 +91,31 @@ class _AchievementCelebrationHostState ); if (!context.mounted) return; final unlocks = service.takePending(); - if (unlocks.isEmpty) return; - await AchievementUnlockedDialog.show( - context, - unlocks, - view: service.state.value, - onSeeAll: () => AppRouter.router.push('/achievements'), - ); + if (unlocks.isNotEmpty) { + await AchievementUnlockedDialog.show( + context, + unlocks, + view: service.state.value, + onSeeAll: () => AppRouter.router.push('/achievements'), + ); + } + // A badge often brings the level with it; one after the other. + final up = _progress?.takeLevelUp(); + if (up != null && context.mounted) { + await LevelUpDialog.show( + context, + up, + onOpen: () => AppRouter.router.push('/progress'), + ); + } } finally { _showing = false; } - if (mounted && service.pending.value.isNotEmpty) _schedule(); + if (mounted && + (service.pending.value.isNotEmpty || + _progress?.pendingLevelUp.value != null)) { + _schedule(); + } } @override diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index ad5b2e29..ce4b5a52 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -8,6 +8,8 @@ import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/player/player_dock.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/my_list/data/datasources/my_list_local_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; import 'package:soplay/features/stats/data/watch_stats_store.dart'; import 'package:soplay/features/streak/data/streak_service.dart'; import 'package:soplay/features/auth/data/models/user_model.dart'; @@ -325,6 +327,12 @@ class AuthRepositoryImpl implements AuthRepository { if (getIt.isRegistered()) { getIt().reset(); } + if (getIt.isRegistered()) { + await getIt().reset(); + } + if (getIt.isRegistered()) { + await getIt().reset(); + } // Not server-backed, and cleared for exactly that reason: nothing would // ever remove it otherwise, and a watch-time total is as personal as the // history it was counted from. diff --git a/lib/features/comments/data/models/comment_author_model.dart b/lib/features/comments/data/models/comment_author_model.dart index 513ecae5..101bfbba 100644 --- a/lib/features/comments/data/models/comment_author_model.dart +++ b/lib/features/comments/data/models/comment_author_model.dart @@ -1,4 +1,5 @@ import 'package:soplay/features/comments/domain/entities/comment_author.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; class CommentAuthorModel extends CommentAuthor { const CommentAuthorModel({ @@ -6,6 +7,7 @@ class CommentAuthorModel extends CommentAuthor { required super.username, super.displayName, super.photoURL, + super.decoration, }); factory CommentAuthorModel.fromJson(Map json) => @@ -14,5 +16,6 @@ class CommentAuthorModel extends CommentAuthor { username: json['username'] as String? ?? '', displayName: json['displayName'] as String?, photoURL: json['photoURL'] as String?, + decoration: UserDecoration.fromJson(json), ); } diff --git a/lib/features/comments/domain/entities/comment_author.dart b/lib/features/comments/domain/entities/comment_author.dart index 94f2c633..5ecae7ca 100644 --- a/lib/features/comments/domain/entities/comment_author.dart +++ b/lib/features/comments/domain/entities/comment_author.dart @@ -1,14 +1,18 @@ +import 'package:soplay/features/progress/domain/progress.dart'; + class CommentAuthor { final String id; final String username; final String? displayName; final String? photoURL; + final UserDecoration? decoration; const CommentAuthor({ required this.id, required this.username, this.displayName, this.photoURL, + this.decoration, }); String get nameOrUsername => diff --git a/lib/features/comments/presentation/widgets/comment_card.dart b/lib/features/comments/presentation/widgets/comment_card.dart index ed66865b..b9bfedde 100644 --- a/lib/features/comments/presentation/widgets/comment_card.dart +++ b/lib/features/comments/presentation/widgets/comment_card.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/reports/domain/entities/report_payload.dart'; import 'package:soplay/features/reports/presentation/widgets/report_sheet.dart'; import 'comment_avatar.dart'; @@ -61,7 +62,14 @@ class CommentCard extends StatelessWidget { '/u/${Uri.encodeComponent(comment.user.username)}', ) : null, - child: CommentAvatar(author: comment.user, size: compact ? 30 : 36), + child: FramedAvatar( + size: compact ? 30 : 36, + frame: comment.user.decoration?.frame, + child: CommentAvatar( + author: comment.user, + size: compact ? 30 : 36, + ), + ), ), const SizedBox(width: 10), Expanded( @@ -71,10 +79,9 @@ class CommentCard extends StatelessWidget { Row( children: [ Flexible( - child: Text( - comment.user.nameOrUsername, - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: UserBadgeLine( + name: comment.user.nameOrUsername, + decoration: comment.user.decoration, style: const TextStyle( color: AppColors.textPrimary, fontSize: 13, diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 31055dfb..6f25d259 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -14,6 +14,7 @@ import 'package:soplay/core/player/color_profile.dart'; import 'package:soplay/core/player/shader_presets.dart'; import 'package:soplay/core/player/shader_store.dart'; import 'package:soplay/features/detail/data/title_prefs_store.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; import 'package:soplay/features/stats/data/watch_stats_store.dart'; import 'package:soplay/core/network/external_dio.dart'; import 'package:soplay/core/network/cf_bypass_service.dart'; diff --git a/lib/features/detail/presentation/pages/player_page.history.dart b/lib/features/detail/presentation/pages/player_page.history.dart index 5ea409e3..71434454 100644 --- a/lib/features/detail/presentation/pages/player_page.history.dart +++ b/lib/features/detail/presentation/pages/player_page.history.dart @@ -161,7 +161,13 @@ extension _PlayerHistory on _PlayerPageState { if (elapsed <= 0) return; if (_hive.isIncognito) return; unawaited( - _watchStats.record(seconds: elapsed, provider: widget.args.provider), + _watchStats + .record(seconds: elapsed, provider: widget.args.provider) + .then((_) { + if (getIt.isRegistered()) { + getIt().onWatched(); + } + }), ); } diff --git a/lib/features/profile/data/backup_service.dart b/lib/features/profile/data/backup_service.dart index 58102c13..5aa758be 100644 --- a/lib/features/profile/data/backup_service.dart +++ b/lib/features/profile/data/backup_service.dart @@ -107,6 +107,9 @@ class BackupService { // Which household profile this device acts as; the account decides that. ProfileSession.cacheKey, ProfileSession.activeKey, + // Names this device to the XP counter; a copy on a second device would + // make the two share one watch-time total. + 'progress_device_id', }; /// The file always holds one person's data under the plain names, so it diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index f3a28419..5177a08e 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -50,6 +50,9 @@ import 'package:soplay/core/storage/profile_scope.dart'; import 'package:soplay/features/social/data/social_service.dart'; import 'package:soplay/features/social/domain/social_models.dart'; import 'package:soplay/features/achievements/presentation/widgets/achievements_entry_card.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; // Split into parts the way player_page.dart is: the page was 2721 lines // across nine sections, which is past the point where anyone can find diff --git a/lib/features/profile/presentation/pages/profile_page.header.dart b/lib/features/profile/presentation/pages/profile_page.header.dart index f1ea1594..fde32c19 100644 --- a/lib/features/profile/presentation/pages/profile_page.header.dart +++ b/lib/features/profile/presentation/pages/profile_page.header.dart @@ -186,11 +186,23 @@ class _UserContent extends StatelessWidget { if (user.authProvider != 'local') 'Google', ].join(' · '); - return Padding( + return ValueListenableBuilder( + valueListenable: getIt.isRegistered() + ? getIt().state + : _noProgress, + builder: (context, progress, _) => Padding( padding: const EdgeInsets.all(16), child: Row( children: [ - _Avatar(user: user), + GestureDetector( + onTap: progress == null ? null : () => context.push('/progress'), + child: FramedAvatar( + size: 72, + radius: 20, + frame: progress?.frame, + child: _Avatar(user: user), + ), + ), const SizedBox(width: 14), Expanded( child: Column( @@ -199,7 +211,9 @@ class _UserContent extends StatelessWidget { Text( name, style: TextStyle( - color: AppColors.textPrimary, + color: + Cosmetics.nameColor(progress?.nameColor) ?? + AppColors.textPrimary, fontSize: 19, fontWeight: FontWeight.w800, height: lineHeightFor(context, 1.15), @@ -229,6 +243,10 @@ class _UserContent extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ], + if (progress != null) ...[ + const SizedBox(height: 8), + _LevelLink(view: progress), + ], ], ), ), @@ -236,6 +254,52 @@ class _UserContent extends StatelessWidget { _EditProfileButton(user: user), ], ), + ), + ); + } +} + +final ValueNotifier _noProgress = ValueNotifier(null); + +/// The level under the name; the way into the progress page. +class _LevelLink extends StatelessWidget { + const _LevelLink({required this.view}); + + final ProgressView view; + + @override + Widget build(BuildContext context) { + final p = view.progress; + final title = Cosmetics.titleText(view.title); + return InkWell( + onTap: () => context.push('/progress'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + LevelChip(level: p.level, fontSize: 11), + const SizedBox(width: 8), + Flexible( + child: Text( + title ?? 'progress.to_next_short'.tr(args: ['${p.toNext}']), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + size: 16, + color: AppColors.textHint, + ), + ], + ), + ), ); } } diff --git a/lib/features/progress/data/progress_remote_data_source.dart b/lib/features/progress/data/progress_remote_data_source.dart new file mode 100644 index 00000000..19d50c45 --- /dev/null +++ b/lib/features/progress/data/progress_remote_data_source.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; + +class ProgressRemoteDataSource { + const ProgressRemoteDataSource({required this.dio}); + + final Dio dio; + + Map _body(Response res) { + final data = res.data; + return data is Map ? data.cast() : const {}; + } + + Future> me() async => + _body(await dio.get('/progress/me')); + + /// [seconds] is this device's running total for local [day], not a delta: + /// the server credits only the rise, so a retry adds nothing. + Future> watchTime({ + required String day, + required int seconds, + required String deviceId, + }) async => _body( + await dio.post( + '/progress/watch-time', + data: {'day': day, 'seconds': seconds, 'deviceId': deviceId}, + ), + ); + + Future> equip(Map slots) async => + _body(await dio.put('/progress/cosmetics', data: slots)); +} diff --git a/lib/features/progress/data/progress_service.dart b/lib/features/progress/data/progress_service.dart new file mode 100644 index 00000000..0a229497 --- /dev/null +++ b/lib/features/progress/data/progress_service.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/storage/profile_scope.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; + +/// A level reached since the viewer last saw their level, and what it opened. +class LevelUp { + const LevelUp({required this.from, required this.to, this.unlocked = const []}); + + final int from; + final int to; + final List unlocked; + + /// What reaching [level] means against the [seen] level, if anything. The + /// first level ever seen is a baseline, not a celebration. + static LevelUp? between(int? seen, ProgressView view) { + final level = view.level; + if (seen == null || level <= seen) return null; + return LevelUp( + from: seen, + to: level, + unlocked: [ + for (final c in view.catalog) + if (c.level != null && c.level! > seen && c.level! <= level) c, + ], + ); + } +} + +/// The active profile's XP, level and cosmetics. +/// +/// Null [state] means nothing is known — signed out, or a server without +/// `/progress` yet — and every decoration hides. +class ProgressService { + ProgressService({ + required ProgressRemoteDataSource remote, + required HiveService hive, + }) : _remote = remote, + _hive = hive { + state.value = _readCache(); + } + + final ProgressRemoteDataSource _remote; + final HiveService _hive; + + final ValueNotifier state = ValueNotifier(null); + final ValueNotifier pendingLevelUp = ValueNotifier(null); + + static const String viewKey = 'progress_view'; + static const String seenLevelKey = 'progress_seen_level'; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + ProgressView? _readCache() { + try { + final raw = _box?.get(ProfileScope.key(viewKey)); + if (raw is String && raw.isNotEmpty) { + return ProgressView.fromJson( + (jsonDecode(raw) as Map).cast(), + ); + } + } catch (_) {} + return null; + } + + /// Takes a progress body from any `/progress` answer. + Future apply(Map raw) async { + if (raw['xp'] is! num) return; + final view = ProgressView.fromJson(raw); + state.value = view; + final box = _box; + try { + await box?.put(ProfileScope.key(viewKey), jsonEncode(raw)); + final seen = box?.get(ProfileScope.key(seenLevelKey)); + final up = LevelUp.between(seen is int ? seen : null, view); + if (up != null) { + final had = pendingLevelUp.value; + pendingLevelUp.value = had == null + ? up + : LevelUp( + from: had.from, + to: up.to, + unlocked: [...had.unlocked, ...up.unlocked], + ); + } + if (seen is! int || view.level > seen) { + await box?.put(ProfileScope.key(seenLevelKey), view.level); + } + } catch (_) {} + } + + bool _inFlight = false; + + Future refresh() async { + if (!_hive.isLoggedIn || _inFlight) return; + _inFlight = true; + try { + await apply(await _remote.me()); + } catch (e) { + debugPrint('[progress] refresh failed: $e'); + } finally { + _inFlight = false; + } + } + + /// Puts [id] on [slot], or takes the slot off with null. Throws on failure + /// so the locker can say so. + Future equip(CosmeticSlot slot, String? id) async { + await apply(await _remote.equip({slot.name: id})); + } + + LevelUp? takeLevelUp() { + final out = pendingLevelUp.value; + pendingLevelUp.value = null; + return out; + } + + /// A profile switch: what is on screen belonged to someone else. + void reload() { + pendingLevelUp.value = null; + state.value = _readCache(); + unawaited(refresh()); + } + + /// Sign-out: nothing of the account stays on the device. + Future reset() async { + pendingLevelUp.value = null; + state.value = null; + try { + await _box?.delete(ProfileScope.key(viewKey)); + await _box?.delete(ProfileScope.key(seenLevelKey)); + } catch (_) {} + } +} diff --git a/lib/features/progress/data/watch_time_uploader.dart b/lib/features/progress/data/watch_time_uploader.dart new file mode 100644 index 00000000..3d377eb4 --- /dev/null +++ b/lib/features/progress/data/watch_time_uploader.dart @@ -0,0 +1,193 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:dio/dio.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/storage/profile_scope.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/stats/data/watch_stats_store.dart'; + +/// Which days' watch time still has to reach the server. +/// +/// The server is sent each day's running total, and it credits the rise since +/// the last total it saw. So what is pending is just the gap between +/// [WatchStatsStore.byDay] and what was last sent — it survives being offline +/// with nothing extra to keep, and a retry after a lost answer adds nothing. +class WatchTimeBatch { + WatchTimeBatch._(); + + static const Duration interval = Duration(minutes: 10); + + /// Today and yesterday: the server takes nothing older. + static List> due({ + required Map byDay, + required Map sent, + required DateTime now, + }) { + final days = [dayKey(now.subtract(const Duration(days: 1))), dayKey(now)]; + return [ + for (final d in days) + if ((byDay[d] ?? 0) > (sent[d] ?? 0)) MapEntry(d, byDay[d]!), + ]; + } + + /// [sent] without the days that can no longer be sent. + static Map pruned(Map sent, DateTime now) { + final oldest = dayKey(now.subtract(const Duration(days: 7))); + return { + for (final e in sent.entries) + if (e.key.compareTo(oldest) >= 0) e.key: e.value, + }; + } + + /// Whether a tick while watching should send now. + static bool shouldFlush(DateTime? last, DateTime now) => + last == null || now.difference(last) >= interval; + + static String dayKey(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; +} + +/// Sends watch time for XP: every ten minutes while something plays, and when +/// the app goes to the background. +class WatchTimeUploader with WidgetsBindingObserver { + WatchTimeUploader({ + required ProgressRemoteDataSource remote, + required HiveService hive, + required ProgressService progress, + WatchStatsStore? stats, + DateTime Function()? clock, + }) : _remote = remote, + _hive = hive, + _progress = progress, + _stats = stats ?? WatchStatsStore(), + _clock = clock ?? DateTime.now; + + final ProgressRemoteDataSource _remote; + final HiveService _hive; + final ProgressService _progress; + final WatchStatsStore _stats; + final DateTime Function() _clock; + + static const String sentKey = 'progress_watch_sent'; + static const String _deviceKey = 'progress_device_id'; + + DateTime? _lastFlush; + Future? _running; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + String get _deviceId { + final box = _box; + final had = box?.get(_deviceKey); + if (had is String && had.isNotEmpty) return had; + final rnd = math.Random.secure(); + final id = List.generate( + 16, + (_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + unawaited(box?.put(_deviceKey, id)); + return id; + } + + Map _readSent() { + final raw = _box?.get(ProfileScope.key(sentKey)); + if (raw is! Map) return {}; + return { + for (final e in raw.entries) + if (e.value is num) e.key.toString(): (e.value as num).toInt(), + }; + } + + void start() { + WidgetsBinding.instance.addObserver(this); + _lastFlush = _clock(); + unawaited(flush()); + unawaited(_progress.refresh()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + unawaited(flush()); + case AppLifecycleState.resumed: + unawaited(_progress.refresh()); + default: + break; + } + } + + /// A tick of watching was just recorded. + void onWatched() { + if (WatchTimeBatch.shouldFlush(_lastFlush, _clock())) unawaited(flush()); + } + + /// Sends whatever is pending. A failure leaves it pending for next time. + Future flush() => _running ??= _flush().whenComplete(() { + _running = null; + }); + + Future _flush() async { + _lastFlush = _clock(); + if (!_hive.isLoggedIn) return; + final now = _clock(); + var sent = WatchTimeBatch.pruned(_readSent(), now); + final due = WatchTimeBatch.due( + byDay: _stats.byDay, + sent: sent, + now: now, + ); + if (due.isEmpty) return; + final device = _deviceId; + final key = ProfileScope.key(sentKey); + for (final entry in due) { + try { + final res = await _remote.watchTime( + day: entry.key, + seconds: entry.value, + deviceId: device, + ); + sent = {...sent, entry.key: entry.value}; + await _box?.put(key, sent); + final progress = res['progress']; + if (progress is Map) { + await _progress.apply(progress.cast()); + } + } on DioException catch (e) { + // A day the server will never take would block every day after it. + if (e.response?.statusCode == 400) { + sent = {...sent, entry.key: entry.value}; + await _box?.put(key, sent); + continue; + } + debugPrint('[progress] watch-time upload failed: $e'); + return; + } catch (e) { + debugPrint('[progress] watch-time upload failed: $e'); + return; + } + } + } + + /// Sign-out: the totals this was measured against are gone too. + Future reset() async { + try { + await _box?.delete(ProfileScope.key(sentKey)); + } catch (_) {} + } +} diff --git a/lib/features/progress/domain/progress.dart b/lib/features/progress/domain/progress.dart new file mode 100644 index 00000000..5c3137d9 --- /dev/null +++ b/lib/features/progress/domain/progress.dart @@ -0,0 +1,221 @@ +/// XP, levels and cosmetics, as `/progress` describes them. Parsing is +/// lenient for the same reason as everywhere else: an old app keeps talking to +/// a newer server. +library; + +import 'dart:math' as math; + +/// Cumulative XP level [n] needs: 100·n^1.5, the server's own curve. +int xpForLevel(int n) => n <= 0 ? 0 : (100 * math.pow(n, 1.5)).floor(); + +/// The level [xp] cumulative XP reaches. +int levelFor(int xp) { + if (xp <= 0) return 0; + var n = math.pow(xp / 100, 2 / 3).floor(); + while (xpForLevel(n + 1) <= xp) { + n++; + } + while (n > 0 && xpForLevel(n) > xp) { + n--; + } + return n; +} + +/// Where an XP total sits inside its level, for a ring or a bar. +class LevelProgress { + const LevelProgress._(this.level, this.xp, this.floor, this.next); + + factory LevelProgress.of(int xp) { + final x = math.max(0, xp); + final level = levelFor(x); + return LevelProgress._(level, x, xpForLevel(level), xpForLevel(level + 1)); + } + + final int level; + final int xp; + + /// XP the current level started at. + final int floor; + + /// XP the next level starts at. + final int next; + + int get intoLevel => xp - floor; + int get span => next - floor; + int get toNext => next - xp; + double get fraction => + span <= 0 ? 0 : (intoLevel / span).clamp(0.0, 1.0).toDouble(); +} + +int _int(Object? v) => v is num ? v.toInt() : 0; + +String? _str(Object? v) { + if (v is! String) return null; + return v.isEmpty ? null : v; +} + +Map _map(Object? v) => + v is Map ? v.cast() : const {}; + +/// What others see beside a name. Null wherever the server sent no level, +/// which is also what a server without this feature sends. +class UserDecoration { + const UserDecoration({ + required this.level, + this.frame, + this.title, + this.nameColor, + }); + + final int level; + final String? frame; + final String? title; + final String? nameColor; + + static UserDecoration? fromJson(Map json) { + final level = json['level']; + if (level is! num) return null; + return UserDecoration( + level: level.toInt(), + frame: _str(json['frame']), + title: _str(json['title']), + nameColor: _str(json['nameColor']), + ); + } +} + +enum CosmeticSlot { + frame, + title, + nameColor; + + static CosmeticSlot? parse(String raw) => switch (raw) { + 'frame' => frame, + 'title' => title, + 'nameColor' => nameColor, + _ => null, + }; +} + +/// One frame, title or colour, and what unlocks it. +class CosmeticItem { + const CosmeticItem({ + required this.slot, + required this.id, + required this.unlocked, + this.level, + this.badge, + this.tier = 1, + }); + + final CosmeticSlot slot; + final String id; + final bool unlocked; + + /// The level that unlocks it, or null when a badge does. + final int? level; + final String? badge; + final int tier; + + factory CosmeticItem.fromJson(CosmeticSlot slot, Map json) { + final unlock = _map(json['unlock']); + return CosmeticItem( + slot: slot, + id: _str(json['id']) ?? '', + unlocked: json['unlocked'] == true, + level: unlock['level'] is num ? _int(unlock['level']) : null, + badge: _str(unlock['badge']), + tier: unlock['tier'] is num ? _int(unlock['tier']) : 1, + ); + } +} + +class XpGain { + const XpGain({required this.reason, required this.xp, this.at}); + + final String reason; + final int xp; + final DateTime? at; + + factory XpGain.fromJson(Map json) => XpGain( + reason: _str(json['reason']) ?? '', + xp: _int(json['xp']), + at: json['at'] is String + ? DateTime.tryParse(json['at'] as String)?.toLocal() + : null, + ); +} + +class ProgressView { + const ProgressView({ + required this.xp, + this.watchSeconds = 0, + this.commentCount = 0, + this.frame, + this.title, + this.nameColor, + this.recent = const [], + this.catalog = const [], + this.watchXpToday = 0, + this.watchCap = 300, + }); + + final int xp; + final int watchSeconds; + final int commentCount; + final String? frame; + final String? title; + final String? nameColor; + final List recent; + final List catalog; + final int watchXpToday; + final int watchCap; + + LevelProgress get progress => LevelProgress.of(xp); + int get level => progress.level; + + UserDecoration get decoration => UserDecoration( + level: level, + frame: frame, + title: title, + nameColor: nameColor, + ); + + List slot(CosmeticSlot s) => + catalog.where((c) => c.slot == s && c.id.isNotEmpty).toList(); + + String? equipped(CosmeticSlot s) => switch (s) { + CosmeticSlot.frame => frame, + CosmeticSlot.title => title, + CosmeticSlot.nameColor => nameColor, + }; + + factory ProgressView.fromJson(Map json) { + final equipped = _map(json['equipped']); + final today = _map(json['today']); + final catalog = []; + for (final e in _map(json['catalog']).entries) { + final slot = CosmeticSlot.parse(e.key); + if (slot == null || e.value is! List) continue; + for (final item in (e.value as List).whereType()) { + catalog.add(CosmeticItem.fromJson(slot, item.cast())); + } + } + return ProgressView( + xp: _int(json['xp']), + watchSeconds: _int(json['watchSeconds']), + commentCount: _int(json['commentCount']), + frame: _str(equipped['frame']), + title: _str(equipped['title']), + nameColor: _str(equipped['nameColor']), + recent: [ + if (json['recent'] is List) + for (final g in (json['recent'] as List).whereType()) + XpGain.fromJson(g.cast()), + ], + catalog: catalog, + watchXpToday: _int(today['watchXp']), + watchCap: today['watchCap'] is num ? _int(today['watchCap']) : 300, + ); + } +} diff --git a/lib/features/progress/presentation/dialogs/level_up_dialog.dart b/lib/features/progress/presentation/dialogs/level_up_dialog.dart new file mode 100644 index 00000000..b99ec033 --- /dev/null +++ b/lib/features/progress/presentation/dialogs/level_up_dialog.dart @@ -0,0 +1,150 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/presentation/widgets/level_ring.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; + +/// "Level 12": the new level, and what it opened in the locker. +class LevelUpDialog extends StatelessWidget { + const LevelUpDialog({super.key, required this.levelUp, this.onOpen}); + + final LevelUp levelUp; + final VoidCallback? onOpen; + + static Future show( + BuildContext context, + LevelUp levelUp, { + VoidCallback? onOpen, + }) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Level up', + barrierColor: Colors.black.withValues(alpha: 0.8), + transitionDuration: const Duration(milliseconds: 320), + pageBuilder: (_, _, _) => LevelUpDialog(levelUp: levelUp, onOpen: onOpen), + transitionBuilder: (_, anim, _, child) { + final t = Curves.easeOutBack.transform(anim.value.clamp(0.0, 1.0)); + return Opacity( + opacity: anim.value, + child: Transform.scale(scale: 0.85 + 0.15 * t, child: child), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final unlocked = levelUp.unlocked + .where((c) => Cosmetics.isKnown(c.slot, c.id)) + .take(4) + .toList(); + return Material( + type: MaterialType.transparency, + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 28), + constraints: const BoxConstraints(maxWidth: 360), + padding: const EdgeInsets.fromLTRB(24, 28, 24, 18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.surface, AppColors.background], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + width: 0.8, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.18), + blurRadius: 34, + spreadRadius: 2, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'progress.level_up'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.primaryLight, + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 1.8, + ), + ), + const SizedBox(height: 18), + LevelRing(level: levelUp.to, fraction: 1, size: 132), + const SizedBox(height: 16), + Text( + 'progress.level_reached'.tr(args: ['${levelUp.to}']), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + if (unlocked.isNotEmpty) ...[ + const SizedBox(height: 14), + Text( + 'progress.unlocked_new'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + ), + ), + const SizedBox(height: 8), + Wrap( + alignment: WrapAlignment.center, + spacing: 6, + runSpacing: 6, + children: [ + for (final c in unlocked) + Chip( + label: Text(Cosmetics.label(c.slot, c.id)), + visualDensity: VisualDensity.compact, + backgroundColor: Colors.white.withValues(alpha: 0.06), + side: BorderSide.none, + labelStyle: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + ), + ), + ], + ), + ], + const SizedBox(height: 18), + Row( + children: [ + if (onOpen != null) + Expanded( + child: TextButton( + onPressed: () { + Navigator.of(context).pop(); + onOpen!(); + }, + child: Text('progress.open_locker'.tr()), + ), + ), + Expanded( + child: FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('achievements.great'.tr()), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/progress/presentation/pages/progress_page.dart b/lib/features/progress/presentation/pages/progress_page.dart new file mode 100644 index 00000000..d2a57b18 --- /dev/null +++ b/lib/features/progress/presentation/pages/progress_page.dart @@ -0,0 +1,583 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.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/features/achievements/domain/achievements.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; +import 'package:soplay/features/progress/presentation/widgets/level_ring.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; +import 'package:soplay/features/streak/presentation/widgets/streak_badge.dart'; + +/// The level, how it was earned, and the locker of what it unlocks. +class ProgressPage extends StatefulWidget { + const ProgressPage({super.key}); + + @override + State createState() => _ProgressPageState(); +} + +class _ProgressPageState extends State { + final ProgressService _service = getIt(); + bool _loading = true; + String? _saving; + + @override + void initState() { + super.initState(); + _service.state.addListener(_onChange); + _service.refresh().whenComplete(() { + if (mounted) setState(() => _loading = false); + }); + } + + void _onChange() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _service.state.removeListener(_onChange); + super.dispose(); + } + + Future _equip(CosmeticItem item, bool equipped) async { + if (_saving != null) return; + setState(() => _saving = '${item.slot.name}:${item.id}'); + try { + await _service.equip(item.slot, equipped ? null : item.id); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('achievements.save_failed'.tr())), + ); + } + } finally { + if (mounted) setState(() => _saving = null); + } + } + + @override + Widget build(BuildContext context) { + final view = _service.state.value; + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + title: Text('progress.title'.tr()), + actions: const [StreakBadge(), SizedBox(width: 8)], + ), + body: view == null + ? Center( + child: _loading + ? const CircularProgressIndicator() + : Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'progress.unavailable'.tr(), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary), + ), + ), + ) + : RefreshIndicator( + onRefresh: _service.refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + _LevelCard(view: view), + const SizedBox(height: 16), + _SectionTitle('progress.how_to_earn'.tr()), + const _Rules(), + if (view.recent.isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionTitle('progress.recent'.tr()), + _Recent(gains: view.recent.take(8).toList()), + ], + const SizedBox(height: 16), + _SectionTitle('progress.locker'.tr()), + for (final slot in CosmeticSlot.values) + _LockerSlot( + slot: slot, + view: view, + saving: _saving, + onTap: _equip, + ), + ], + ), + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Text( + text, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +BoxDecoration _card() => BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withValues(alpha: 0.06), width: 0.5), +); + +class _LevelCard extends StatelessWidget { + const _LevelCard({required this.view}); + + final ProgressView view; + + @override + Widget build(BuildContext context) { + final p = view.progress; + final user = getIt().getUser(); + final display = user?.displayName?.trim(); + final name = display != null && display.isNotEmpty + ? display + : user?.displayIdentifier ?? ''; + final photo = user?.photoURL; + return Container( + padding: const EdgeInsets.all(18), + decoration: _card(), + child: Column( + children: [ + Row( + children: [ + LevelRing(level: p.level, fraction: p.fraction, size: 112), + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'progress.xp_total'.tr(args: ['${p.xp}']), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + 'progress.to_next'.tr( + args: ['${p.toNext}', '${p.level + 1}'], + ), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + ), + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: view.watchCap <= 0 + ? 0.0 + : (view.watchXpToday / view.watchCap).clamp( + 0.0, + 1.0, + ), + minHeight: 5, + backgroundColor: Colors.white.withValues(alpha: 0.08), + ), + ), + const SizedBox(height: 4), + Text( + 'progress.watch_today'.tr( + args: ['${view.watchXpToday}', '${view.watchCap}'], + ), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + ), + ), + ], + ), + ), + ], + ), + if (name.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + children: [ + FramedAvatar( + size: 40, + frame: view.frame, + child: CircleAvatar( + radius: 20, + backgroundColor: AppColors.surfaceVariant, + backgroundImage: photo != null && photo.isNotEmpty + ? NetworkImage(photo) + : null, + child: photo != null && photo.isNotEmpty + ? null + : Text(name.characters.first.toUpperCase()), + ), + ), + const SizedBox(width: 12), + Expanded( + child: UserBadgeLine( + name: name, + decoration: view.decoration, + showTitle: true, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _Rules extends StatelessWidget { + const _Rules(); + + @override + Widget build(BuildContext context) { + final rules = [ + (Icons.play_circle_outline_rounded, 'progress.rule_watch'.tr()), + (Icons.chat_bubble_outline_rounded, 'progress.rule_comment'.tr()), + (Icons.favorite_border_rounded, 'progress.rule_like'.tr()), + (Icons.local_fire_department_rounded, 'progress.rule_streak'.tr()), + (Icons.emoji_events_outlined, 'progress.rule_achievement'.tr()), + ]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: _card(), + child: Column( + children: [ + for (final (icon, text) in rules) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.primaryLight), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + height: 1.3, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _reasonKey(String reason) => switch (reason) { + 'watch' => 'progress.reason_watch', + 'comment' => 'progress.reason_comment', + 'like' => 'progress.reason_like', + 'streak' => 'progress.reason_streak', + 'achievement' => 'progress.reason_achievement', + _ => 'progress.reason_other', +}; + +class _Recent extends StatelessWidget { + const _Recent({required this.gains}); + + final List gains; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: _card(), + child: Column( + children: [ + for (final g in gains) + Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Expanded( + child: Text( + _reasonKey(g.reason).tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13.5, + ), + ), + ), + if (g.at != null) + Padding( + padding: const EdgeInsets.only(right: 10), + child: Text( + DateFormat.MMMd( + context.locale.toString(), + ).format(g.at!), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + ), + ), + ), + Text( + 'progress.xp_gain'.tr(args: ['${g.xp}']), + style: TextStyle( + color: AppColors.primaryLight, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _slotKey(CosmeticSlot slot) => switch (slot) { + CosmeticSlot.frame => 'progress.slot_frame', + CosmeticSlot.title => 'progress.slot_title', + CosmeticSlot.nameColor => 'progress.slot_color', +}; + +/// How to get a locked item: a level, or a badge at a tier. +String unlockHint(CosmeticItem item) { + final badge = item.badge; + if (item.level != null || badge == null) { + return 'progress.unlock_level'.tr(args: ['${item.level ?? 0}']); + } + final def = AchievementDef.of(badge); + final name = def.nameKey.tr(); + if (def.isSingle) return 'progress.unlock_badge'.tr(args: [name]); + return 'progress.unlock_badge'.tr( + args: ['$name · ${MedalTier.ofLevel(item.tier).labelKey.tr()}'], + ); +} + +class _LockerSlot extends StatelessWidget { + const _LockerSlot({ + required this.slot, + required this.view, + required this.saving, + required this.onTap, + }); + + final CosmeticSlot slot; + final ProgressView view; + final String? saving; + final Future Function(CosmeticItem item, bool equipped) onTap; + + @override + Widget build(BuildContext context) { + final items = view + .slot(slot) + .where((c) => Cosmetics.isKnown(slot, c.id)) + .toList(); + if (items.isEmpty) return const SizedBox.shrink(); + final current = view.equipped(slot); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Container( + padding: const EdgeInsets.all(12), + decoration: _card(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), + child: Text( + _slotKey(slot).tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final item in items) + _LockerItem( + item: item, + equipped: current == item.id, + busy: saving == '${slot.name}:${item.id}', + onTap: item.unlocked + ? () => onTap(item, current == item.id) + : null, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _LockerItem extends StatelessWidget { + const _LockerItem({ + required this.item, + required this.equipped, + required this.busy, + required this.onTap, + }); + + final CosmeticItem item; + final bool equipped; + final bool busy; + final VoidCallback? onTap; + + Widget _preview() { + switch (item.slot) { + case CosmeticSlot.frame: + return FramedAvatar( + size: 34, + frame: item.id, + child: const CircleAvatar( + radius: 17, + backgroundColor: Color(0xFF2A2A2A), + child: Icon(Icons.person_rounded, size: 20, color: Colors.white54), + ), + ); + case CosmeticSlot.nameColor: + return Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: Cosmetics.nameColor(item.id), + shape: BoxShape.circle, + ), + ); + case CosmeticSlot.title: + return const Icon( + Icons.workspace_premium_outlined, + size: 24, + color: AppColors.textSecondary, + ); + } + } + + @override + Widget build(BuildContext context) { + final locked = !item.unlocked; + final label = Cosmetics.label(item.slot, item.id); + return Tooltip( + message: locked ? unlockHint(item) : label, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + width: 100, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + decoration: BoxDecoration( + color: equipped + ? AppColors.primary.withValues(alpha: 0.14) + : Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: equipped + ? AppColors.primary.withValues(alpha: 0.6) + : Colors.white.withValues(alpha: 0.06), + ), + ), + child: Opacity( + opacity: locked ? 0.5 : 1, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 44, + child: Center( + child: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _preview(), + ), + ), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: item.slot == CosmeticSlot.nameColor + ? Cosmetics.nameColor(item.id) + : AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (locked) ...[ + const Icon( + Icons.lock_outline_rounded, + size: 11, + color: AppColors.textHint, + ), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + locked + ? unlockHint(item) + : equipped + ? 'progress.equipped'.tr() + : 'progress.equip'.tr(), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10.5, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/progress/presentation/widgets/level_ring.dart b/lib/features/progress/presentation/widgets/level_ring.dart new file mode 100644 index 00000000..98509181 --- /dev/null +++ b/lib/features/progress/presentation/widgets/level_ring.dart @@ -0,0 +1,108 @@ +import 'dart:math' as math; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; + +/// The level in the middle, the way through it around the edge. +class LevelRing extends StatelessWidget { + const LevelRing({ + super.key, + required this.level, + required this.fraction, + this.size = 120, + }); + + final int level; + final double fraction; + final double size; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: fraction), + duration: const Duration(milliseconds: 700), + curve: Curves.easeOutCubic, + builder: (_, t, child) => CustomPaint( + painter: _RingPainter( + fraction: t, + color: AppColors.primary, + track: Colors.white.withValues(alpha: 0.08), + stroke: size * 0.075, + ), + child: child, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'progress.level'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.textSecondary, + fontSize: size * 0.09, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + Text( + '$level', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: size * 0.32, + fontWeight: FontWeight.w900, + height: 1.05, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + _RingPainter({ + required this.fraction, + required this.color, + required this.track, + required this.stroke, + }); + + final double fraction; + final Color color; + final Color track; + final double stroke; + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final arc = rect.deflate(stroke / 2); + final base = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = track; + canvas.drawArc(arc, 0, math.pi * 2, false, base); + if (fraction <= 0) return; + final fill = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round + ..shader = SweepGradient( + startAngle: -math.pi / 2, + endAngle: math.pi * 1.5, + colors: [color.withValues(alpha: 0.6), color], + transform: const GradientRotation(-math.pi / 2), + ).createShader(rect); + canvas.drawArc(arc, -math.pi / 2, math.pi * 2 * fraction, false, fill); + } + + @override + bool shouldRepaint(_RingPainter old) => + old.fraction != fraction || old.color != color || old.stroke != stroke; +} diff --git a/lib/features/progress/presentation/widgets/user_badge_line.dart b/lib/features/progress/presentation/widgets/user_badge_line.dart new file mode 100644 index 00000000..59399cc2 --- /dev/null +++ b/lib/features/progress/presentation/widgets/user_badge_line.dart @@ -0,0 +1,221 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; + +/// How each cosmetic id looks. The server only stores ids; an id this build +/// does not know draws as nothing rather than as a broken frame. +class Cosmetics { + Cosmetics._(); + + static const Map> frames = { + 'bronze': [Color(0xFFE0A374), Color(0xFF9C5B2E)], + 'silver': [Color(0xFFF2F2F2), Color(0xFF9DA3AB)], + 'gold': [Color(0xFFFFE58A), Color(0xFFD4A017)], + 'neon': [Color(0xFF3DF5FF), Color(0xFFB44DFF)], + 'sakura': [Color(0xFFFFC6DD), Color(0xFFFF6FA5)], + 'ember': [Color(0xFFFFC078), Color(0xFFEF4A1A)], + 'flame': [Color(0xFFFFE066), Color(0xFFFF7A1A), Color(0xFFE5243B)], + 'moon': [Color(0xFFDDE6FF), Color(0xFF4C5BD4)], + }; + + static const Map nameColors = { + 'sky': Color(0xFF6EC1FF), + 'mint': Color(0xFF5EE6B0), + 'gold': Color(0xFFFFD45E), + 'rose': Color(0xFFFF7FA8), + 'violet': Color(0xFFB98CFF), + 'crimson': Color(0xFFFF5A5F), + 'aurora': Color(0xFF7CF2D8), + 'ember': Color(0xFFFF9F43), + }; + + static const Set titles = { + 'newcomer', + 'regular', + 'veteran', + 'legend', + 'binger', + 'cinephile', + 'otaku', + 'bookworm', + 'night_owl', + 'host', + }; + + static List? frame(String? id) => id == null ? null : frames[id]; + + static Color? nameColor(String? id) => id == null ? null : nameColors[id]; + + /// The words for a title, or null for one this build has none for. + static String? titleText(String? id) => + id != null && titles.contains(id) ? 'progress.title_$id'.tr() : null; + + static bool isKnown(CosmeticSlot slot, String id) => switch (slot) { + CosmeticSlot.frame => frames.containsKey(id), + CosmeticSlot.title => titles.contains(id), + CosmeticSlot.nameColor => nameColors.containsKey(id), + }; + + /// The name a locker shows for any cosmetic. + static String label(CosmeticSlot slot, String id) => switch (slot) { + CosmeticSlot.frame => 'progress.frame_$id'.tr(), + CosmeticSlot.title => 'progress.title_$id'.tr(), + CosmeticSlot.nameColor => 'progress.color_$id'.tr(), + }; +} + +/// An avatar inside the frame its owner equipped; the avatar alone without +/// one. +class FramedAvatar extends StatelessWidget { + const FramedAvatar({ + super.key, + required this.size, + required this.child, + this.frame, + this.radius, + }); + + /// The avatar's own size; the frame is drawn around it, not into it. + final double size; + final Widget child; + final String? frame; + + /// Corner radius for a square avatar; null for a circle. + final double? radius; + + static double ringFor(double size) => + (size * 0.07).clamp(2.0, 5.0).toDouble(); + + @override + Widget build(BuildContext context) { + final colors = Cosmetics.frame(frame); + if (colors == null) return child; + final ring = ringFor(size); + final shape = radius == null + ? const BoxDecoration(shape: BoxShape.circle) + : BoxDecoration(borderRadius: BorderRadius.circular(radius! + ring)); + return Container( + width: size + ring * 2, + height: size + ring * 2, + padding: EdgeInsets.all(ring), + decoration: shape.copyWith( + gradient: SweepGradient(colors: [...colors, colors.first]), + boxShadow: [ + BoxShadow( + color: colors.last.withValues(alpha: 0.35), + blurRadius: ring * 2.5, + ), + ], + ), + child: child, + ); + } +} + +/// "Lv 12" as a small pill. +class LevelChip extends StatelessWidget { + const LevelChip({super.key, required this.level, this.fontSize = 10}); + + final int level; + final double fontSize; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: fontSize * 0.55, + vertical: fontSize * 0.15, + ), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(fontSize), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.45), + width: 0.6, + ), + ), + child: Text( + 'progress.level_short'.tr(args: ['$level']), + style: TextStyle( + color: AppColors.textPrimary, + fontSize: fontSize, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + ); + } +} + +/// A name in its owner's colour, their level beside it and, when asked, their +/// title under it. With no [decoration] it is just the name. +class UserBadgeLine extends StatelessWidget { + const UserBadgeLine({ + super.key, + required this.name, + required this.decoration, + required this.style, + this.showTitle = false, + this.center = false, + this.chipSize, + }); + + final String name; + final UserDecoration? decoration; + final TextStyle style; + final bool showTitle; + final bool center; + final double? chipSize; + + @override + Widget build(BuildContext context) { + final d = decoration; + final color = Cosmetics.nameColor(d?.nameColor); + final line = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: center ? TextAlign.center : null, + style: color == null ? style : style.copyWith(color: color), + ), + ), + if (d != null && d.level > 0) ...[ + const SizedBox(width: 6), + LevelChip( + level: d.level, + fontSize: chipSize ?? ((style.fontSize ?? 13) * 0.72), + ), + ], + ], + ); + final title = showTitle ? Cosmetics.titleText(d?.title) : null; + if (title == null) return line; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: center + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + line, + const SizedBox(height: 2), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: (color ?? AppColors.textSecondary).withValues(alpha: 0.85), + fontSize: (style.fontSize ?? 13) * 0.78, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ], + ); + } +} diff --git a/lib/features/social/domain/social_models.dart b/lib/features/social/domain/social_models.dart index 8f177f97..4f75b7a3 100644 --- a/lib/features/social/domain/social_models.dart +++ b/lib/features/social/domain/social_models.dart @@ -4,6 +4,7 @@ library; import 'package:soplay/features/achievements/domain/achievements.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; enum SocialRelation { self, @@ -65,6 +66,7 @@ class SocialUser { required this.username, this.displayName, this.photoURL, + this.decoration, }); final String id; @@ -72,6 +74,9 @@ class SocialUser { final String? displayName; final String? photoURL; + /// Level and cosmetics; null from a server that sends none. + final UserDecoration? decoration; + String get name => (displayName?.trim().isNotEmpty ?? false) ? displayName!.trim() : username; @@ -81,6 +86,7 @@ class SocialUser { username: _str(json['username']) ?? '', displayName: _str(json['displayName']), photoURL: _str(json['photoURL']), + decoration: UserDecoration.fromJson(json), ); } diff --git a/lib/features/social/presentation/pages/user_profile_page.dart b/lib/features/social/presentation/pages/user_profile_page.dart index 13c84d91..48f6007a 100644 --- a/lib/features/social/presentation/pages/user_profile_page.dart +++ b/lib/features/social/presentation/pages/user_profile_page.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/social/data/social_service.dart'; import 'package:soplay/features/social/domain/social_models.dart'; import 'package:soplay/features/social/presentation/cursor_pager.dart'; @@ -449,9 +450,11 @@ class _Header extends StatelessWidget { children: [ SocialAvatar(user: user, size: 84), const SizedBox(height: 12), - Text( - user.name, - textAlign: TextAlign.center, + UserBadgeLine( + name: user.name, + decoration: user.decoration, + showTitle: true, + center: true, style: const TextStyle( color: AppColors.textPrimary, fontSize: 19, diff --git a/lib/features/social/presentation/widgets/activity_card.dart b/lib/features/social/presentation/widgets/activity_card.dart index cdf33661..d836b0b3 100644 --- a/lib/features/social/presentation/widgets/activity_card.dart +++ b/lib/features/social/presentation/widgets/activity_card.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/social/domain/social_models.dart'; import 'package:soplay/features/social/presentation/widgets/social_widgets.dart'; import 'package:soplay/features/achievements/domain/achievements.dart'; @@ -257,6 +258,7 @@ class _ActorLine extends StatelessWidget { @override Widget build(BuildContext context) { + final level = actor.decoration?.level ?? 0; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => context.push('/u/${Uri.encodeComponent(actor.username)}'), @@ -270,11 +272,21 @@ class _ActorLine extends StatelessWidget { children: [ TextSpan( text: actor.name, - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: + Cosmetics.nameColor(actor.decoration?.nameColor) ?? + AppColors.textPrimary, fontWeight: FontWeight.w700, ), ), + if (level > 0) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 5), + child: LevelChip(level: level, fontSize: 9), + ), + ), TextSpan(text: ' $verb'), ], ), diff --git a/lib/features/social/presentation/widgets/social_widgets.dart b/lib/features/social/presentation/widgets/social_widgets.dart index 67237f95..17052fa6 100644 --- a/lib/features/social/presentation/widgets/social_widgets.dart +++ b/lib/features/social/presentation/widgets/social_widgets.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/system/app_dates.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/social/domain/social_models.dart'; String socialErrorText(Object? error) { @@ -103,7 +104,7 @@ class SocialAvatar extends StatelessWidget { ), ), ); - return Container( + final avatar = Container( width: size, height: size, decoration: BoxDecoration( @@ -122,6 +123,11 @@ class SocialAvatar extends StatelessWidget { errorWidget: (_, _, _) => placeholder, ), ); + return FramedAvatar( + size: size, + frame: user.decoration?.frame, + child: avatar, + ); } } @@ -155,10 +161,9 @@ class SocialUserTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - user.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, + UserBadgeLine( + name: user.name, + decoration: user.decoration, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14.5, diff --git a/test/features/progress/progress_test.dart b/test/features/progress/progress_test.dart new file mode 100644 index 00000000..12e58786 --- /dev/null +++ b/test/features/progress/progress_test.dart @@ -0,0 +1,281 @@ +// XP and levels: the curve the server uses, what a progress answer parses +// to, and how watch time is batched up to the server. +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; +import 'package:soplay/features/stats/data/watch_stats_store.dart'; + +class _Hive implements HiveService { + @override + bool get isLoggedIn => true; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Remote extends ProgressRemoteDataSource { + _Remote() : super(dio: Dio()); + + final List> sent = []; + bool offline = false; + int xp = 0; + + @override + Future> me() async => {'xp': xp}; + + @override + Future> watchTime({ + required String day, + required int seconds, + required String deviceId, + }) async { + if (offline) throw DioException(requestOptions: RequestOptions()); + sent.add({'day': day, 'seconds': seconds, 'deviceId': deviceId}); + xp += seconds ~/ 60; + return { + 'progress': {'xp': xp}, + }; + } +} + +void main() { + group('the level curve', () { + test('level n needs 100·n^1.5 XP, as on the server', () { + expect(xpForLevel(0), 0); + expect(xpForLevel(1), 100); + expect(xpForLevel(2), 282); + expect(xpForLevel(3), 519); + expect(xpForLevel(10), 3162); + expect(levelFor(0), 0); + expect(levelFor(99), 0); + expect(levelFor(100), 1); + expect(levelFor(281), 1); + expect(levelFor(282), 2); + for (var n = 1; n < 200; n++) { + expect(levelFor(xpForLevel(n)), n, reason: 'at level $n'); + expect(levelFor(xpForLevel(n) - 1), n - 1, reason: 'short of $n'); + } + }); + + test('the ring shows the way through the current level', () { + final p = LevelProgress.of(400); + expect(p.level, 2); + expect(p.floor, 282); + expect(p.next, 519); + expect(p.intoLevel, 118); + expect(p.toNext, 119); + expect(p.fraction, closeTo(118 / 237, 1e-9)); + expect(LevelProgress.of(0).fraction, 0); + expect(LevelProgress.of(-5).level, 0); + }); + }); + + group('parsing', () { + test('no level means no decoration', () { + expect(UserDecoration.fromJson({'username': 'a'}), isNull); + expect(UserDecoration.fromJson({'level': null}), isNull); + final d = UserDecoration.fromJson({ + 'level': 7, + 'frame': 'gold', + 'title': '', + 'nameColor': 'mint', + })!; + expect(d.level, 7); + expect(d.frame, 'gold'); + expect(d.title, isNull); + expect(d.nameColor, 'mint'); + }); + + test('a progress answer parses its catalogue and gains', () { + final v = ProgressView.fromJson({ + 'xp': 300, + 'equipped': {'frame': 'bronze', 'title': null, 'nameColor': null}, + 'today': {'watchXp': 40, 'watchCap': 300}, + 'recent': [ + {'reason': 'watch', 'xp': 40, 'at': '2026-11-07T10:00:00Z'}, + ], + 'catalog': { + 'frame': [ + { + 'id': 'bronze', + 'unlock': {'level': 2}, + 'unlocked': true, + }, + { + 'id': 'flame', + 'unlock': {'badge': 'streak', 'tier': 2}, + 'unlocked': false, + }, + ], + 'title': [ + { + 'id': 'newcomer', + 'unlock': {'level': 0}, + 'unlocked': true, + }, + ], + 'future_slot': [ + {'id': 'x'}, + ], + }, + }); + expect(v.level, 2); + expect(v.decoration.frame, 'bronze'); + expect(v.slot(CosmeticSlot.frame).map((c) => c.id), ['bronze', 'flame']); + final flame = v.slot(CosmeticSlot.frame).last; + expect(flame.level, isNull); + expect(flame.badge, 'streak'); + expect(flame.tier, 2); + expect(v.catalog.length, 3); + expect(v.recent.single.xp, 40); + expect(v.watchXpToday, 40); + }); + + test('a level-up is only what rose past the level last seen', () { + final v = ProgressView.fromJson({ + 'xp': 600, + 'catalog': { + 'frame': [ + { + 'id': 'bronze', + 'unlock': {'level': 2}, + }, + { + 'id': 'silver', + 'unlock': {'level': 5}, + }, + ], + }, + }); + expect(LevelUp.between(null, v), isNull, reason: 'first sight'); + expect(LevelUp.between(3, v), isNull); + final up = LevelUp.between(1, v)!; + expect(up.from, 1); + expect(up.to, 3); + expect(up.unlocked.map((c) => c.id), ['bronze']); + }); + }); + + group('batching', () { + final now = DateTime(2026, 11, 7, 21); + + test('only days that grew are due, today and yesterday only', () { + final due = WatchTimeBatch.due( + byDay: {'2026-11-05': 900, '2026-11-06': 600, '2026-11-07': 300}, + sent: {'2026-11-06': 600, '2026-11-07': 120}, + now: now, + ); + expect(due.map((e) => '${e.key}=${e.value}'), ['2026-11-07=300']); + }); + + test('nothing is due once everything was sent', () { + expect( + WatchTimeBatch.due( + byDay: {'2026-11-07': 300}, + sent: {'2026-11-07': 300}, + now: now, + ), + isEmpty, + ); + }); + + test('old days are forgotten', () { + final kept = WatchTimeBatch.pruned({ + '2026-10-01': 1, + '2026-10-31': 2, + '2026-11-06': 3, + }, now); + expect(kept.keys, ['2026-10-31', '2026-11-06']); + }); + + test('a tick sends at most every ten minutes', () { + expect(WatchTimeBatch.shouldFlush(null, now), isTrue); + expect( + WatchTimeBatch.shouldFlush( + now.subtract(const Duration(minutes: 9)), + now, + ), + isFalse, + ); + expect( + WatchTimeBatch.shouldFlush( + now.subtract(const Duration(minutes: 10)), + now, + ), + isTrue, + ); + }); + }); + + group('the uploader', () { + late Directory dir; + + setUpAll(() async { + dir = await Directory.systemTemp.createTemp('sozo_progress_'); + Hive.init(dir.path); + await Hive.openBox(AppConstants.settingsBox); + }); + + tearDownAll(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + + setUp(() async { + await WatchStatsStore().clear(); + await Hive.box(AppConstants.settingsBox).clear(); + }); + + test('sends the day total, survives being offline, and never twice', () async { + final remote = _Remote(); + final progress = ProgressService(remote: remote, hive: _Hive()); + final uploader = WatchTimeUploader( + remote: remote, + hive: _Hive(), + progress: progress, + ); + final stats = WatchStatsStore(); + final today = WatchTimeBatch.dayKey(DateTime.now()); + + await stats.record(seconds: 300, provider: 'p'); + remote.offline = true; + await uploader.flush(); + expect(remote.sent, isEmpty); + + await stats.record(seconds: 120, provider: 'p'); + remote.offline = false; + await uploader.flush(); + expect(remote.sent.single['day'], today); + expect(remote.sent.single['seconds'], 420); + expect(progress.state.value?.xp, 7); + + await uploader.flush(); + expect(remote.sent.length, 1, reason: 'nothing new to send'); + + await stats.record(seconds: 60, provider: 'p'); + await uploader.flush(); + expect(remote.sent.last['seconds'], 480); + expect(remote.sent.last['deviceId'], remote.sent.first['deviceId']); + }); + + test('a level gained is waiting for the celebration', () async { + final remote = _Remote(); + final progress = ProgressService(remote: remote, hive: _Hive()); + remote.xp = 50; + await progress.refresh(); + expect(progress.pendingLevelUp.value, isNull); + remote.xp = 300; + await progress.refresh(); + expect(progress.pendingLevelUp.value?.to, 2); + expect(progress.takeLevelUp()?.from, 0); + expect(progress.pendingLevelUp.value, isNull); + }); + }); +}