From 4654213355e1231b369f38ed2fef8bf6ce97695d Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:47:30 +0500 Subject: [PATCH] Add XP, levels and profile cosmetics A progress page with the level ring, recent XP and a locker for avatar frames, titles and name colours. Watch time goes to the server every ten minutes while playing and when the app goes to the background, as each day's running total so a retry adds nothing. Comment authors, social cards, activity cards and the profile header show the frame, name colour and level. A level-up is celebrated by the achievement celebration host. Nothing shows until the server answers /progress. --- assets/translations/ar.json | 62 ++ assets/translations/ckb.json | 62 ++ assets/translations/de.json | 62 ++ assets/translations/en.json | 62 ++ assets/translations/es.json | 62 ++ assets/translations/fr.json | 62 ++ assets/translations/hi.json | 62 ++ assets/translations/id.json | 62 ++ assets/translations/ku.json | 62 ++ assets/translations/nl.json | 62 ++ assets/translations/pt.json | 62 ++ assets/translations/ru.json | 62 ++ assets/translations/tr.json | 62 ++ assets/translations/uz.json | 62 ++ assets/translations/vi.json | 62 ++ assets/translations/yue.json | 62 ++ lib/core/di/injection.dart | 16 + lib/core/router/app_router.dart | 5 + lib/core/storage/profile_scope.dart | 3 + .../widgets/achievement_celebration_host.dart | 41 +- .../repositories/auth_repository_impl.dart | 8 + .../data/models/comment_author_model.dart | 3 + .../domain/entities/comment_author.dart | 4 + .../presentation/widgets/comment_card.dart | 17 +- .../presentation/pages/player_page.dart | 1 + .../pages/player_page.history.dart | 8 +- lib/features/profile/data/backup_service.dart | 3 + .../presentation/pages/profile_page.dart | 3 + .../pages/profile_page.header.dart | 70 ++- .../data/progress_remote_data_source.dart | 31 + .../progress/data/progress_service.dart | 147 +++++ .../progress/data/watch_time_uploader.dart | 193 ++++++ lib/features/progress/domain/progress.dart | 221 +++++++ .../presentation/dialogs/level_up_dialog.dart | 150 +++++ .../presentation/pages/progress_page.dart | 583 ++++++++++++++++++ .../presentation/widgets/level_ring.dart | 108 ++++ .../presentation/widgets/user_badge_line.dart | 221 +++++++ lib/features/social/domain/social_models.dart | 6 + .../presentation/pages/user_profile_page.dart | 9 +- .../presentation/widgets/activity_card.dart | 16 +- .../presentation/widgets/social_widgets.dart | 15 +- test/features/progress/progress_test.dart | 281 +++++++++ 42 files changed, 3127 insertions(+), 28 deletions(-) create mode 100644 lib/features/progress/data/progress_remote_data_source.dart create mode 100644 lib/features/progress/data/progress_service.dart create mode 100644 lib/features/progress/data/watch_time_uploader.dart create mode 100644 lib/features/progress/domain/progress.dart create mode 100644 lib/features/progress/presentation/dialogs/level_up_dialog.dart create mode 100644 lib/features/progress/presentation/pages/progress_page.dart create mode 100644 lib/features/progress/presentation/widgets/level_ring.dart create mode 100644 lib/features/progress/presentation/widgets/user_badge_line.dart create mode 100644 test/features/progress/progress_test.dart 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); + }); + }); +}