diff --git a/android/app/src/main/kotlin/com/soplay/sozo/cloudstream/PluginHost.kt b/android/app/src/main/kotlin/com/soplay/sozo/cloudstream/PluginHost.kt index dc094062..f3792422 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/cloudstream/PluginHost.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/cloudstream/PluginHost.kt @@ -17,6 +17,7 @@ import com.lagradost.cloudstream3.TvSeriesLoadResponse import com.lagradost.cloudstream3.plugins.BasePlugin import com.lagradost.cloudstream3.plugins.Plugin import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType import dalvik.system.PathClassLoader import org.json.JSONArray import org.json.JSONObject @@ -431,9 +432,14 @@ class PluginHost(private val appContext: Context) { }.toString() } + // AudioFile is @Prerelease in the CloudStream library; the annotation is an + // IDE hint with BINARY retention, so reading the list a plugin may have filled + // costs nothing at runtime and is empty for every plugin that sets none. + @OptIn(com.lagradost.cloudstream3.Prerelease::class) suspend fun loadLinksJson(providerName: String, data: String): String { val api = apiByName(providerName) - val videoSources = JSONArray() + // (quality, source) pairs so the list can be ordered before it is emitted. + val collected = ArrayList>() val subs = JSONArray() val seenUrls = HashSet() val seenSubs = HashSet() @@ -446,27 +452,69 @@ class PluginHost(private val appContext: Context) { if (sf.url.isNotEmpty() && seenSubs.add(sf.url)) { subs.put(JSONObject().apply { put("label", sf.lang); put("file", sf.url); put("default", false) + // A subtitle is fetched on its own, so it inherits none of + // the stream's headers. Plenty of CloudStream sources hand + // out tracks that 403 without the Referer the extractor + // set, and SubtitleFile has carried those headers since + // v4.7 — dropping them is why a provider could return + // subtitles and the player still show none. + sf.headers?.takeIf { it.isNotEmpty() }?.let { + put("headers", JSONObject(it as Map<*, *>)) + } }) } }, callback = { link: ExtractorLink -> - if (link.url.isNotEmpty() && seenUrls.add(link.url)) { - val headers = JSONObject(link.headers as Map<*, *>) - if (link.referer.isNotEmpty()) headers.put("Referer", link.referer) + // Torrents and magnets are not streams. Passed through as an + // ordinary source they reached the player as a bare "Source + // error"; the honest thing is to not offer them. Relative urls + // ("dl.php?id=…") go for the same reason — ExoPlayer resolves + // those as a local file path. + val streamable = link.type != ExtractorLinkType.TORRENT && + link.type != ExtractorLinkType.MAGNET + if (streamable && link.url.startsWith("http", ignoreCase = true) && + seenUrls.add(link.url) + ) { + // getAllHeaders() folds in the referer exactly the way + // CloudStream's own player does, instead of us overwriting a + // Referer an extractor had deliberately set. + val headers = JSONObject(link.getAllHeaders() as Map<*, *>) // quality is a resolution int (e.g. 1080) or a Qualities // sentinel; build a readable, distinct " · p". val q = link.quality val res = if (q in 144..4320) "${q}p" else null val nm = link.name.ifBlank { "Source" } val label = if (res != null) "$nm · $res" else nm - videoSources.put(JSONObject().apply { + collected.add((if (q in 144..4320) q else 0) to JSONObject().apply { put("quality", label) put("videoUrl", link.url) - put("type", if (link.isM3u8) "hls" else "http") + // DASH used to fall into the `else` branch and be handed + // over as a plain progressive file, so every .mpd source + // failed to open. + put("type", when (link.type) { + ExtractorLinkType.M3U8 -> "hls" + ExtractorLinkType.DASH -> "dash" + else -> "http" + }) put("host", link.name) - put("isDefault", videoSources.length() == 0) put("accessible", true) put("headers", headers) + // Separate audio renditions the extractor says belong with + // this video. A dual-audio release puts its dub here rather + // than in the manifest, so discarding them left the player + // with one track and nothing to switch to. + if (link.audioTracks.isNotEmpty()) { + put("audioTracks", JSONArray().apply { + link.audioTracks.forEach { a -> + put(JSONObject().apply { + put("url", a.url) + a.headers?.takeIf { it.isNotEmpty() }?.let { + put("headers", JSONObject(it as Map<*, *>)) + } + }) + } + }) + } }) } } @@ -474,7 +522,18 @@ class PluginHost(private val appContext: Context) { } catch (t: Throwable) { Log.e(TAG, "loadLinks ${api.name}: ${t.javaClass.simpleName}: ${t.message}") } - Log.i(TAG, "loadLinks ${api.name}: ${videoSources.length()} source(s), ${subs.length()} sub(s)") + Log.i(TAG, "loadLinks ${api.name}: ${collected.size} source(s), ${subs.length()} sub(s)") + } + // Best first. Extractors call back in whatever order they finish, so the + // default source used to be a race: a 360p mirror that resolved quickly + // won over a 1080p one that took a moment longer. CloudStream's own + // player orders by quality for the same reason. The sort is stable, so + // sources of equal quality keep the order the provider produced them in. + collected.sortByDescending { it.first } + val videoSources = JSONArray() + collected.forEachIndexed { i, entry -> + entry.second.put("isDefault", i == 0) + videoSources.put(entry.second) } // Shape matches MediaResolveModel.fromJson (videoUrl/type/headers + videoSources + subtitles). val first = if (videoSources.length() > 0) videoSources.getJSONObject(0) else null diff --git a/assets/translations/en.json b/assets/translations/en.json index 44850587..59f199e4 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -228,7 +228,9 @@ "ai_translate_remaining": "{}/{} translations left today", "ai_translate_used_up": "You've used today's translation limit", "ai_translate_menu": "AI translate", - "ai_translate_menu_desc": "Translates a subtitle to {}" + "ai_translate_menu_desc": "Translates a subtitle to {}", + "ready_translations": "Ready translations", + "lines": "lines" }, "profile": { "title": "Profile", @@ -272,6 +274,9 @@ "sign_out": "Sign Out", "sign_out_confirm": "Are you sure you want to sign out?", "section_providers": "PROVIDERS", + "section_content": "CONTENT", + "sources_row": "Extension sources", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Provider", "choose_provider": "Choose Provider", "search_providers_hint": "Search providers…", @@ -350,6 +355,47 @@ "remove_photo": "Remove photo", "photo_pick_failed": "Could not pick a photo" }, + "appearance": { + "title": "Appearance", + "entry_subtitle": "Accent colour, pure black", + "section_preview": "PREVIEW", + "section_accent": "ACCENT COLOUR", + "accent_footnote": "The accent colours buttons, highlights, progress bars and the active tab — everywhere in the app.", + "accent_custom": "Custom", + "accent_red": "Sozo Red", + "accent_ember": "Ember", + "accent_amber": "Amber", + "accent_emerald": "Emerald", + "accent_teal": "Teal", + "accent_sky": "Sky", + "accent_ocean": "Ocean", + "accent_indigo": "Indigo", + "accent_violet": "Violet", + "accent_magenta": "Magenta", + "accent_rose": "Rose", + "accent_slate": "Slate", + "section_darkness": "BACKGROUND", + "darkness_dark": "Dark", + "darkness_dark_desc": "The standard dark greys", + "darkness_black": "Pure black", + "darkness_black_desc": "True black backgrounds", + "amoled_footnote": "On an OLED screen a black pixel is switched off, so pure black gives deeper contrast and uses less battery.", + "reset": "Reset to default", + "reset_tooltip": "Back to the default look", + "custom_title": "Custom colour", + "custom_hue": "HUE", + "custom_saturation": "SATURATION", + "custom_lightness": "LIGHTNESS", + "custom_apply": "Use this colour", + "custom_adjusted": "Darkened a little so white text stays readable on it.", + "section_library": "FROM YOUR LIBRARY", + "library_footnote": "Colours taken from the posters of what you have been watching.", + "section_extras": "MORE", + "tint_nav": "Colour the tab bar", + "tint_nav_desc": "Paint the selected tab in your accent instead of white", + "shuffle": "Try the next colour", + "shuffle_desc": "Step through the palette one accent at a time" + }, "search": { "title": "Search", "hint": "Search movies, series...", @@ -1161,17 +1207,25 @@ }, "live_tv": { "title": "Live TV", - "all": "All", - "favourites": "FAVOURITES", - "all_channels": "ALL CHANNELS", + "favourites": "Favourites", + "recent": "Recently watched", + "categories": "Categories", + "countries": "Countries", + "results": "Search results", "search_hint": "Channel name…", + "search_in": "Search in {folder}…", "no_match": "No channel matches that.", "empty": "No channels yet.", "load_failed": "Couldn't load the channels.", "retry": "Try again", - "recent": "RECENTLY WATCHED", - "folders": "FOLDERS", - "search_in": "Search in {folder}…", + "now": "Now", + "next": "Next", + "guide": "TV guide", + "no_guide": "No guide for this channel.", + "watch": "Watch", + "favourite_add": "Add to favourites", + "favourite_remove": "Remove from favourites", + "hint_long_press": "Long-press a channel for the guide and favourites.", "channel_count": { "one": "{} channel", "other": "{} channels" diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 91776ebd..45f87f98 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -228,7 +228,9 @@ "ai_translate_remaining": "Сегодня осталось {}/{}", "ai_translate_used_up": "Дневной лимит перевода исчерпан", "ai_translate_menu": "ИИ перевод", - "ai_translate_menu_desc": "Переводит субтитры на {}" + "ai_translate_menu_desc": "Переводит субтитры на {}", + "ready_translations": "Готовые переводы", + "lines": "строк" }, "profile": { "title": "Профиль", @@ -272,6 +274,9 @@ "sign_out": "Выйти", "sign_out_confirm": "Вы уверены, что хотите выйти?", "section_providers": "ИСТОЧНИКИ", + "section_content": "КОНТЕНТ", + "sources_row": "Источники расширений", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Источник", "choose_provider": "Выберите источник", "search_providers_hint": "Поиск источников…", @@ -350,6 +355,47 @@ "remove_photo": "Удалить фото", "photo_pick_failed": "Не удалось выбрать фото" }, + "appearance": { + "title": "Внешний вид", + "entry_subtitle": "Акцентный цвет, чёрный фон", + "section_preview": "ПРЕДПРОСМОТР", + "section_accent": "АКЦЕНТНЫЙ ЦВЕТ", + "accent_footnote": "Акцентный цвет применяется к кнопкам, выделениям, полосам прогресса и активной вкладке — во всём приложении.", + "accent_custom": "Свой цвет", + "accent_red": "Красный Sozo", + "accent_ember": "Уголь", + "accent_amber": "Янтарь", + "accent_emerald": "Изумруд", + "accent_teal": "Бирюза", + "accent_sky": "Небо", + "accent_ocean": "Океан", + "accent_indigo": "Индиго", + "accent_violet": "Фиолетовый", + "accent_magenta": "Пурпурный", + "accent_rose": "Розовый", + "accent_slate": "Серый", + "section_darkness": "ФОН", + "darkness_dark": "Тёмный", + "darkness_dark_desc": "Обычный тёмно-серый фон", + "darkness_black": "Чистый чёрный", + "darkness_black_desc": "Полностью чёрный фон", + "amoled_footnote": "На OLED-экране чёрный пиксель просто не горит, поэтому чистый чёрный даёт более глубокий контраст и экономит заряд.", + "reset": "Сбросить по умолчанию", + "reset_tooltip": "Вернуть стандартный вид", + "custom_title": "Свой цвет", + "custom_hue": "ОТТЕНОК", + "custom_saturation": "НАСЫЩЕННОСТЬ", + "custom_lightness": "СВЕТЛОТА", + "custom_apply": "Применить цвет", + "custom_adjusted": "Цвет слегка затемнён, чтобы белый текст на нём оставался читаемым.", + "section_library": "ИЗ ВАШЕЙ БИБЛИОТЕКИ", + "library_footnote": "Цвета, взятые с постеров того, что вы смотрите.", + "section_extras": "ДОПОЛНИТЕЛЬНО", + "tint_nav": "Раскрасить панель вкладок", + "tint_nav_desc": "Активная вкладка в вашем цвете вместо белого", + "shuffle": "Попробовать следующий цвет", + "shuffle_desc": "Перебирать палитру по одному цвету" + }, "search": { "title": "Поиск", "hint": "Поиск фильмов, сериалов...", @@ -1161,17 +1207,25 @@ }, "live_tv": { "title": "Эфир", - "all": "Все", - "favourites": "ИЗБРАННОЕ", - "all_channels": "ВСЕ КАНАЛЫ", + "favourites": "Избранное", + "recent": "Недавно смотрели", + "categories": "Категории", + "countries": "Страны", + "results": "Результаты поиска", "search_hint": "Название канала…", + "search_in": "Поиск в {folder}…", "no_match": "Ничего не найдено.", "empty": "Каналов пока нет.", "load_failed": "Не удалось загрузить каналы.", "retry": "Повторить", - "recent": "НЕДАВНО СМОТРЕЛИ", - "folders": "ПАПКИ", - "search_in": "Поиск в {folder}…", + "now": "Сейчас", + "next": "Далее", + "guide": "Телепрограмма", + "no_guide": "Для этого канала нет телепрограммы.", + "watch": "Смотреть", + "favourite_add": "Добавить в избранное", + "favourite_remove": "Убрать из избранного", + "hint_long_press": "Нажмите и удерживайте канал — телепрограмма и избранное.", "channel_count": { "one": "{} канал", "few": "{} канала", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index ee6ed8bc..bb790208 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -228,7 +228,9 @@ "ai_translate_remaining": "Bugun {}/{} tarjima qoldi", "ai_translate_used_up": "Bugungi tarjima chegarangiz tugadi", "ai_translate_menu": "AI tarjima", - "ai_translate_menu_desc": "Subtitrni {} tiliga o'giradi" + "ai_translate_menu_desc": "Subtitrni {} tiliga o'giradi", + "ready_translations": "Tayyor tarjimalar", + "lines": "qator" }, "profile": { "title": "Profil", @@ -272,6 +274,9 @@ "sign_out": "Chiqish", "sign_out_confirm": "Hisobdan chiqmoqchimisiz?", "section_providers": "MANBALAR", + "section_content": "KONTENT", + "sources_row": "Kengaytma manbalari", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Manba", "choose_provider": "Manbani tanlang", "search_providers_hint": "Manbalarni qidirish…", @@ -350,6 +355,47 @@ "remove_photo": "Rasmni olib tashlash", "photo_pick_failed": "Rasmni tanlab bo'lmadi" }, + "appearance": { + "title": "Ko'rinish", + "entry_subtitle": "Asosiy rang, toza qora", + "section_preview": "KO'RIB CHIQISH", + "section_accent": "ASOSIY RANG", + "accent_footnote": "Asosiy rang tugmalar, ajratilgan joylar, progress chiziqlari va faol bo'limga — ilovaning hamma yerida qo'llanadi.", + "accent_custom": "O'zim tanlayman", + "accent_red": "Sozo qizili", + "accent_ember": "Cho'g'", + "accent_amber": "Kahrabo", + "accent_emerald": "Zumrad", + "accent_teal": "Firuza", + "accent_sky": "Osmon", + "accent_ocean": "Okean", + "accent_indigo": "Indigo", + "accent_violet": "Binafsha", + "accent_magenta": "Fuksiya", + "accent_rose": "Atirgul", + "accent_slate": "Kulrang", + "section_darkness": "FON", + "darkness_dark": "Qorong'i", + "darkness_dark_desc": "Odatdagi to'q kulrang fon", + "darkness_black": "Toza qora", + "darkness_black_desc": "Butunlay qora fon", + "amoled_footnote": "OLED ekranda qora piksel umuman yonmaydi — shuning uchun toza qora chuqurroq kontrast beradi va batareyani tejaydi.", + "reset": "Standart holatga qaytarish", + "reset_tooltip": "Standart ko'rinishga qaytarish", + "custom_title": "O'z rangingiz", + "custom_hue": "RANG TUSI", + "custom_saturation": "TO'YINGANLIK", + "custom_lightness": "YORUG'LIK", + "custom_apply": "Shu rangni qo'llash", + "custom_adjusted": "Ustidagi oq yozuv o'qilishi uchun rang bir oz to'qlashtirildi.", + "section_library": "KUTUBXONANGIZDAN", + "library_footnote": "Siz ko'rayotgan kontent posterlaridan olingan ranglar.", + "section_extras": "QO'SHIMCHA", + "tint_nav": "Panelni ranglash", + "tint_nav_desc": "Tanlangan bo'limni oq emas, o'z rangingizda ko'rsatish", + "shuffle": "Keyingi rangni sinash", + "shuffle_desc": "Palitrani birma-bir aylanib chiqish" + }, "search": { "title": "Qidirish", "hint": "Kino, seriallar qidiring...", @@ -1161,17 +1207,25 @@ }, "live_tv": { "title": "Jonli TV", - "all": "Hammasi", - "favourites": "TANLANGANLAR", - "all_channels": "BARCHA KANALLAR", + "favourites": "Tanlanganlar", + "recent": "Yaqinda ko‘rilgan", + "categories": "Kategoriyalar", + "countries": "Davlatlar", + "results": "Qidiruv natijalari", "search_hint": "Kanal nomi…", + "search_in": "{folder} ichida qidirish…", "no_match": "Mos kanal topilmadi.", "empty": "Hozircha kanal yo‘q.", "load_failed": "Kanallarni yuklab bo‘lmadi.", "retry": "Qayta urinish", - "recent": "YAQINDA KO‘RILGAN", - "folders": "PAPKALAR", - "search_in": "{folder} ichida qidirish…", + "now": "Hozir", + "next": "Keyingi", + "guide": "Dastur jadvali", + "no_guide": "Bu kanal uchun dastur jadvali yo‘q.", + "watch": "Ko‘rish", + "favourite_add": "Tanlanganlarga qo‘shish", + "favourite_remove": "Tanlanganlardan olib tashlash", + "hint_long_press": "Dastur jadvali va tanlanganlar uchun kanalni uzoq bosing.", "channel_count": { "one": "{} kanal", "other": "{} kanal" diff --git a/lib/app.dart b/lib/app.dart index 5ba68b24..0224d171 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -18,7 +19,9 @@ import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; import 'core/di/injection.dart'; import 'core/router/app_router.dart'; import 'core/system/desktop_window.dart'; +import 'core/theme/app_colors.dart'; import 'core/theme/app_theme.dart'; +import 'core/theme/theme_controller.dart'; import 'features/auth/presentation/bloc/auth_bloc.dart'; import 'features/home/presentation/bloc/home/home_bloc.dart'; @@ -45,9 +48,12 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { + final ThemeController _theme = getIt(); + @override void initState() { super.initState(); + _theme.addListener(_onThemeChanged); getIt().onTap = _handlePushTap; // Desktop: hide the custom title-bar strip on the immersive full-bleed // routes (player / reader) by watching the router itself — reliable @@ -69,6 +75,7 @@ class _MyAppState extends State { @override void dispose() { + _theme.removeListener(_onThemeChanged); if (isDesktopPlatform) { AppRouter.router.routerDelegate.removeListener(_syncImmersive); } @@ -76,6 +83,54 @@ class _MyAppState extends State { super.dispose(); } + /// Repaint the entire app in the newly chosen colours, without a restart and + /// without losing a single route, scroll offset or piece of widget state. + /// + /// Two things have to happen, and neither one covers the other: + /// + /// * `setState` re-reads [AppTheme.dark], so every Material component + /// default (buttons, inputs, tab indicators, dialogs) picks up the new + /// palette through `Theme.of`, + /// * [_rebuildEverything] marks the rest of the tree dirty, because the + /// ~1900 direct `AppColors.*` reads are plain static getters — they have + /// no `InheritedWidget` to notify them, so nothing else would ever tell + /// those widgets that their colours moved. + void _onThemeChanged() { + if (!mounted) return; + setState(() {}); + _rebuildEverything(); + } + + /// Mark every element below this one for rebuild. + /// + /// `markNeedsBuild` works on the *element*, so it reaches widgets a normal + /// parent rebuild would skip — including `const` ones, which is most of this + /// app's leaf UI. + /// + /// Guarded on the scheduler phase: a notification that arrives mid-build + /// (a theme set from inside a `build`, which nothing does today but is cheap + /// to be safe about) would otherwise assert. In that case it is deferred by + /// one frame instead. + void _rebuildEverything() { + void markDirty(Element element) { + element.markNeedsBuild(); + element.visitChildren(markDirty); + } + + void run() { + if (!mounted) return; + (context as Element).visitChildren(markDirty); + } + + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.persistentCallbacks || + phase == SchedulerPhase.midFrameMicrotasks) { + WidgetsBinding.instance.addPostFrameCallback((_) => run()); + } else { + run(); + } + } + void _handlePushTap(Map data) { final router = AppRouter.router; @@ -131,6 +186,11 @@ class _MyAppState extends State { child: MaterialApp.router( title: 'app_name'.tr(), debugShowCheckedModeBanner: false, + // The colour the OS paints behind the app — the task-switcher card and + // the gap before the first frame. Left at its default it is the theme's + // primary, so an accent change would otherwise leave a red card behind + // a blue app. + color: AppColors.background, theme: AppTheme.dark, darkTheme: AppTheme.dark, themeMode: ThemeMode.dark, diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 2a84470a..837e3cbb 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -94,7 +94,24 @@ class AppConstants { static const String playerEngineKey = 'player_engine'; static const String defaultPlayerEngine = 'default'; static const String telegramPromoSeenKey = 'telegram_promo_seen'; + /// Appearance → "Pure black". Absent ⇒ off, i.e. the greys the app has + /// always shipped. static const String amoledModeKey = 'amoled_mode'; + + /// Appearance → accent colour. Holds an [AppAccent] preset id, or + /// `AppAccent.customId` when the user picked their own colour — in which case + /// the colour itself lives under [customAccentKey]. Absent ⇒ the default red. + static const String accentIdKey = 'accent_id'; + + /// The user's own accent, stored as a 32-bit ARGB int. Only consulted when + /// [accentIdKey] is `AppAccent.customId`. + static const String customAccentKey = 'custom_accent'; + + /// Appearance → "Colour the tab bar". Absent ⇒ **on**: the accent is a + /// setting people choose in order to see it, and the tab bar is the one piece + /// of chrome that is on screen the whole time. Turning it off puts the + /// original white pill back. + static const String tintNavKey = 'tint_nav'; static const String onboardingSeenKey = 'onboarding_seen'; static const String deeplinkPromptSeenKey = 'deeplink_prompt_seen'; static const String deeplinkOptInKey = 'deeplink_opt_in'; diff --git a/lib/core/deeplink/deeplink_opt_in.dart b/lib/core/deeplink/deeplink_opt_in.dart index 03f6a80b..59fe2eca 100644 --- a/lib/core/deeplink/deeplink_opt_in.dart +++ b/lib/core/deeplink/deeplink_opt_in.dart @@ -6,6 +6,7 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'deeplink_settings.dart'; @@ -82,7 +83,7 @@ class _OptInSheet extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.12), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.link_rounded, size: 28, color: AppColors.primary, @@ -116,7 +117,7 @@ class _OptInSheet extends StatelessWidget { backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: () => diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index ce7fdc8a..d49df7ed 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -19,6 +19,7 @@ import 'package:soplay/core/network/provider_interceptor.dart'; import 'package:soplay/core/player/local_hls_proxy.dart'; import 'package:soplay/core/player/webview_stream_extractor.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/theme/theme_controller.dart'; import 'package:soplay/features/anilist/data/airing_reminders.dart'; import 'package:soplay/features/live_tv/data/live_tv_service.dart'; import 'package:soplay/features/remote/data/remote_control_service.dart'; @@ -158,6 +159,13 @@ final getIt = GetIt.instance; Future configureDependencies() async { getIt.registerSingleton(DeeplinkService()); getIt.registerSingleton(HiveService()); + // Eager, and immediately after Hive: the constructor reads the stored accent + // and AMOLED preference and installs the palette synchronously, so the first + // frame after runApp() is already painted in the user's colours instead of + // flashing the default red. + getIt.registerSingleton( + ThemeController(getIt()), + ); getIt.registerSingleton(HistoryService()); getIt.registerSingleton(DownloadService()); diff --git a/lib/core/js/dart_fetch.dart b/lib/core/js/dart_fetch.dart index abf89d9b..964ca071 100644 --- a/lib/core/js/dart_fetch.dart +++ b/lib/core/js/dart_fetch.dart @@ -77,6 +77,13 @@ class DartFetch { final existing = extraHeaders['Cookie'] ?? extraHeaders['cookie']; extraHeaders['Cookie'] = existing != null ? '$cached; $existing' : cached; + // cf_clearance is bound to the agent that earned it, and it was earned + // under the app's own. Letting the extractor's agent ride along with + // the cookie made Cloudflare reissue the challenge on every request + // after the first — one call solved the challenge three times and still + // came back empty. + extraHeaders.remove('user-agent'); + extraHeaders['User-Agent'] = kSozoUserAgent; } } @@ -102,9 +109,17 @@ class DartFetch { cf != null && _looksLikeCfChallenge(status, headers, response.data)) { JsLog.req('fetch', 'CF challenge on $host — solving …'); - final solveAgent = req.headers['User-Agent'] ?? - req.headers['user-agent'] ?? - kSozoUserAgent; + // ALWAYS the app's own agent, never the one the extractor asked for. + // + // The challenge is solved inside an Android WebView that is forced to + // whatever agent we pass here, and several backend extractors ask for a + // desktop one. A desktop agent on an Android WebView is the platform + // mismatch Cloudflare's managed challenge is looking for: it never + // issued cf_clearance, the 30s watchdog expired, and the extension was + // handed the challenge page. Pinning it here means a stale extractor + // cannot reintroduce the mismatch, and the replay below sends the same + // agent the clearance was earned under — which Cloudflare requires. + const solveAgent = kSozoUserAgent; final cookieHeader = await cf.solve( host: host, url: req.url, diff --git a/lib/core/js/js_runtime_service.dart b/lib/core/js/js_runtime_service.dart index c92af31d..b6531d4a 100644 --- a/lib/core/js/js_runtime_service.dart +++ b/lib/core/js/js_runtime_service.dart @@ -172,6 +172,9 @@ class JsRuntimeService { // provider's results under another's name. await _ensureExtractor(extractor.name, extractor.version) .timeout(kJsCallTimeout); + // Any request this call refuses is recorded on DartFetch; clearing it + // first means whatever is left afterwards belongs to THIS call. + dartFetch.clearBlock(); // Unlocked on purpose. The provider is looked up by name, so several // cross-search legs can be in flight at once and their network waits // overlap instead of queueing — which is what made searching several @@ -203,9 +206,15 @@ class JsRuntimeService { } final error = result.error; if (error != null && error.isNotEmpty) { - JsLog.err(tag, '$fn threw: $error'); - throw Exception(error); + // An extractor parses whatever body it is handed, so a Cloudflare + // challenge surfaces as a JSON parse error deep in provider code. When + // the network layer refused something during this call, that refusal is + // the cause and the only half a user can act on. + final blocked = dartFetch.takeBlock(); + JsLog.err(tag, '$fn threw: $error${blocked == null ? '' : ' ($blocked)'}'); + throw Exception(blocked ?? error); } + dartFetch.clearBlock(); final map = _coerceMap(result.value); JsLog.res( tag, diff --git a/lib/core/js/js_timeouts.dart b/lib/core/js/js_timeouts.dart index 9605277a..012c7abf 100644 --- a/lib/core/js/js_timeouts.dart +++ b/lib/core/js/js_timeouts.dart @@ -5,4 +5,9 @@ /// than tight: a single call can legitimately be several requests plus a /// Cloudflare solve, and cutting a slow-but-working source off reads as the /// same bug from the other side. -const Duration kJsCallTimeout = Duration(seconds: 40); +/// +/// 40s was not generous enough for the worst honest case: a Cloudflare-gated +/// source (animepahe) spends up to 30s in [CfBypassService.solve] alone before +/// its first request even starts, and the catalog fetch still has to follow. +/// That timed out mid-solve and looked exactly like a dead provider. +const Duration kJsCallTimeout = Duration(seconds: 60); diff --git a/lib/core/network/cf_bypass_service.dart b/lib/core/network/cf_bypass_service.dart index 6ea816f4..e52fa5df 100644 --- a/lib/core/network/cf_bypass_service.dart +++ b/lib/core/network/cf_bypass_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:soplay/core/js/js_log.dart'; import 'package:soplay/core/system/webview_env.dart'; class CfBypassService { @@ -30,9 +31,16 @@ class CfBypassService { required String userAgent, required Duration timeout, }) async { + final sw = Stopwatch()..start(); final completer = Completer(); Timer? poll; Timer? watchdog; + Timer? retarget; + InAppWebViewController? controller; + + // Cloudflare scopes cf_clearance to the whole zone, so the challenge on any + // page of a host clears every other page of it. + final origin = 'https://$host/'; final headless = HeadlessInAppWebView( webViewEnvironment: await WebViewEnv.ensure(), @@ -44,14 +52,34 @@ class CfBypassService { cacheEnabled: true, useShouldInterceptRequest: false, ), + onWebViewCreated: (c) => controller = c, ); Future stop() async { poll?.cancel(); watchdog?.cancel(); + retarget?.cancel(); try { await headless.dispose(); } catch (_) {} } + // The failing request is often an API endpoint, and a challenge served for + // one is a document the WebView renders, but the page it lands on after + // solving is raw JSON — which Android hands to the download manager instead + // of loading, aborting the navigation partway through the flow. Half a + // budget in, fall back to the host root: it is an ordinary HTML page, it + // carries the same zone-wide challenge, and the clearance it earns is the + // one the original request needed. Costs nothing when the first target + // works, because this timer never fires. + if (url != origin) { + retarget = Timer(timeout ~/ 2, () async { + if (completer.isCompleted) return; + JsLog.info('cf', 'retargeting $host solve to the site root'); + try { + await controller?.loadUrl(urlRequest: URLRequest(url: WebUri(origin))); + } catch (_) {} + }); + } + poll = Timer.periodic(_pollInterval, (_) async { try { final cookies = await CookieManager.instance() @@ -65,14 +93,20 @@ class CfBypassService { .where((c) => '${c.value}'.isNotEmpty) .map((c) => '${c.name}=${c.value}') .join('; '); - if (!completer.isCompleted) completer.complete(header); + if (!completer.isCompleted) { + JsLog.res('cf', 'solved $host', ms: sw.elapsedMilliseconds); + completer.complete(header); + } await stop(); } catch (_) { } }); watchdog = Timer(timeout, () async { - if (!completer.isCompleted) completer.complete(null); + if (!completer.isCompleted) { + JsLog.err('cf', 'no cf_clearance for $host after ${timeout.inSeconds}s'); + completer.complete(null); + } await stop(); }); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index f7beb8e4..02073531 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -50,6 +50,7 @@ import 'package:soplay/features/trivia/presentation/pages/top_fans_page.dart'; import 'package:soplay/features/trivia/presentation/trivia_args.dart'; import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; import 'package:soplay/features/user_lists/presentation/pages/user_lists_page.dart'; +import 'package:soplay/features/profile/presentation/pages/appearance_page.dart'; import 'package:soplay/features/profile/presentation/pages/player_settings_page.dart'; import 'package:soplay/features/profile/presentation/pages/providers_page.dart'; import 'package:soplay/features/live_tv/presentation/pages/live_tv_page.dart'; @@ -239,6 +240,10 @@ class AppRouter { path: '/mal/links', builder: (context, state) => const MalLinksPage(), ), + GoRoute( + path: '/appearance', + builder: (context, state) => const AppearancePage(), + ), GoRoute( path: '/navbar', builder: (context, state) => const NavbarPage(), diff --git a/lib/core/storage/hive_service.dart b/lib/core/storage/hive_service.dart index 0b46ed5c..f9215275 100644 --- a/lib/core/storage/hive_service.dart +++ b/lib/core/storage/hive_service.dart @@ -472,6 +472,38 @@ class HiveService { await _settingsBox.put(AppConstants.amoledModeKey, enabled); } + /// Accent colour id, or `AppAccent.customId`. Empty ⇒ never chosen, so the + /// caller falls back to the shipped default rather than to a stored value. + String get accentId { + final raw = _settingsBox.get(AppConstants.accentIdKey); + return raw is String ? raw : ''; + } + + Future setAccentId(String id) async { + await _settingsBox.put(AppConstants.accentIdKey, id); + } + + /// The user's own accent as a 32-bit ARGB int, or null if they never picked + /// one. Anything non-int in the box is treated as absent: a corrupt value + /// must fall back to a preset, never crash the first paint. + int? get customAccentArgb { + final raw = _settingsBox.get(AppConstants.customAccentKey); + return raw is int ? raw : null; + } + + Future setCustomAccentArgb(int argb) async { + await _settingsBox.put(AppConstants.customAccentKey, argb); + } + + bool get isNavTinted { + return _settingsBox.get(AppConstants.tintNavKey, defaultValue: true) == + true; + } + + Future setNavTinted(bool enabled) async { + await _settingsBox.put(AppConstants.tintNavKey, enabled); + } + bool get hasOnboardingSeen { return _settingsBox.get(AppConstants.onboardingSeenKey, defaultValue: false) == true; } diff --git a/lib/core/subtitles/subtitle_translation_service.dart b/lib/core/subtitles/subtitle_translation_service.dart index b132ae5a..cbb53e70 100644 --- a/lib/core/subtitles/subtitle_translation_service.dart +++ b/lib/core/subtitles/subtitle_translation_service.dart @@ -20,6 +20,18 @@ class SubtitleTranslationResult { final bool cached; } +/// A translation already made for the current media, ready to load. +class ReadySubtitle { + const ReadySubtitle({ + required this.url, + required this.targetLang, + required this.cueCount, + }); + final String url; + final String targetLang; + final int cueCount; +} + /// Today's translation allowance for the account. class SubtitleQuota { const SubtitleQuota({ @@ -52,6 +64,64 @@ class SubtitleDailyLimitReached implements Exception { class SubtitleTranslationService { const SubtitleTranslationService(); + /// Translations already made for this title/episode, across languages. + Future> fetchReady({ + required String tmdbId, + required String type, + int? season, + int? episode, + }) async { + try { + final response = await getIt().get( + '/contents/subtitles/ready', + queryParameters: { + 'tmdbId': tmdbId, + 'type': type, + 'season': ?season, + 'episode': ?episode, + }, + ); + final items = (response.data is Map ? response.data['items'] : null) as List? ?? + const []; + return [ + for (final m in items) + if (m is Map && m['url'] != null) + ReadySubtitle( + url: '${m['url']}', + targetLang: '${m['targetLang'] ?? ''}', + cueCount: (m['cueCount'] as num?)?.toInt() ?? 0, + ), + ]; + } catch (_) { + return const []; + } + } + + /// Publishes a finished translation so other viewers can load it directly. + /// Best-effort — a failure here never blocks playback. + Future publishReady({ + required String tmdbId, + required String type, + int? season, + int? episode, + required String targetLang, + required String srt, + }) async { + try { + await getIt().post( + '/contents/subtitles/ready', + data: { + 'tmdbId': tmdbId, + 'type': type, + 'season': ?season, + 'episode': ?episode, + 'targetLang': targetLang, + 'srt': srt, + }, + ); + } catch (_) {} + } + /// How many translations the account has left today, for showing before the /// person spends one. Returns null if it cannot be read — the UI then simply /// omits the count rather than blocking. diff --git a/lib/core/theme/app_accent.dart b/lib/core/theme/app_accent.dart new file mode 100644 index 00000000..e0f1c2f1 --- /dev/null +++ b/lib/core/theme/app_accent.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; + +/// One accent colour the user can pick in Settings → Appearance. +/// +/// An accent is a *triple*, not a single colour: [base] is what +/// `AppColors.primary` becomes, [dark] and [light] are the pressed / hover / +/// glow variants that `AppColors.primaryDark` and `AppColors.primaryLight` +/// become. Every screen in the app already reads those three, so swapping an +/// accent repaints the whole product without a single call site changing. +/// +/// ## The legibility contract +/// +/// The app is dark-only and puts **white** text and icons on top of +/// `AppColors.primary` in ~140 places (buttons, chips, badges, the play FAB). +/// None of those pass a colour down — they hard-code `Colors.white`. So an +/// accent is only safe if white stays readable on it. +/// +/// Every preset below therefore sits at or above **3.8:1 against white**, and +/// [AppAccent.custom] pushes an arbitrary picked colour down in lightness until +/// it does too. 3.8 is not arbitrary: the shipped Sozo red is 4.79:1 against +/// white and 3.70:1 against the dark background, so the floor is set just under +/// the red's *worse* direction. Any accent the user can choose is at least as +/// legible as the one the app has always shipped, in both directions. +/// +/// ## The family rule +/// +/// [dark] and [light] are not eyeballed per colour — they are the exact +/// relationship the shipped red triple already had, so every accent is a +/// sibling of the original rather than a different-looking design: +/// +/// * `dark` = base scaled by 0.7773 in sRGB (#E50914 → #B20710, exact) +/// * `light` = HSL lightness raised 33.8% of the +/// way to white, saturation × 1.0818 (#E50914 → #FF4B55, exact) +/// +/// The preset triples below are the pre-computed result, so no colour maths +/// runs at startup; [AppAccent.custom] applies the same two rules live. +@immutable +class AppAccent { + const AppAccent({ + required this.id, + required this.base, + required this.dark, + required this.light, + this.isCustom = false, + }); + + /// Stable key persisted in Hive. Never localise or renumber these — an + /// install that stored `'violet'` must still resolve to violet after an + /// update, and an id that no longer exists falls back to [fallback]. + final String id; + + final Color base; + final Color dark; + final Color light; + + /// True only for the user's own picked colour, which is stored as an ARGB + /// value rather than by id. + final bool isCustom; + + /// Translation key for the swatch label. + String get labelKey => isCustom ? 'appearance.accent_custom' : 'appearance.accent_$id'; + + /// The id stored for a custom colour. Its actual value lives under + /// [AppConstants.customAccentKey]. + static const String customId = 'custom'; + + /// The accent the app has always shipped. Also the value a corrupt or + /// unknown stored id falls back to, so a bad preference can never leave the + /// app unthemed. + static const AppAccent fallback = AppAccent( + id: 'red', + base: Color(0xFFE50914), + dark: Color(0xFFB20710), + light: Color(0xFFFF4B55), + ); + + /// The swatch row, in hue order starting from the default red. Slate sits + /// last on purpose: it is the "no colour" choice and reads as the end of the + /// wheel rather than a hue on it. + static const List presets = [ + fallback, + AppAccent( + id: 'ember', + base: Color(0xFFED4700), + dark: Color(0xFFB83700), + light: Color(0xFFFF804A), + ), + AppAccent( + id: 'amber', + base: Color(0xFFB57408), + dark: Color(0xFF8D5A06), + light: Color(0xFFFEAF2C), + ), + AppAccent( + id: 'emerald', + base: Color(0xFF179644), + dark: Color(0xFF127535), + light: Color(0xFF37E876), + ), + AppAccent( + id: 'teal', + base: Color(0xFF0E928E), + dark: Color(0xFF0B716E), + light: Color(0xFF24F3EC), + ), + AppAccent( + id: 'sky', + base: Color(0xFF0C8BC4), + dark: Color(0xFF096C98), + light: Color(0xFF3BBFFB), + ), + AppAccent( + id: 'ocean', + base: Color(0xFF2F7BF6), + dark: Color(0xFF2560BF), + light: Color(0xFF70A6FE), + ), + AppAccent( + id: 'indigo', + base: Color(0xFF6366F1), + dark: Color(0xFF4D4FBB), + light: Color(0xFF9496FA), + ), + AppAccent( + id: 'violet', + base: Color(0xFF9B51E0), + dark: Color(0xFF783FAE), + light: Color(0xFFBD88EE), + ), + AppAccent( + id: 'magenta', + base: Color(0xFFE040A0), + dark: Color(0xFFAE327C), + light: Color(0xFFEF7CC1), + ), + AppAccent( + id: 'rose', + base: Color(0xFFF43657), + dark: Color(0xFFBE2A44), + light: Color(0xFFFD758C), + ), + AppAccent( + id: 'slate', + base: Color(0xFF64748B), + dark: Color(0xFF4E5A6C), + light: Color(0xFF95A2B5), + ), + ]; + + /// Resolve a persisted id. Unknown ids (an accent removed in a later build, a + /// hand-edited box) return null so the caller can fall back deliberately. + static AppAccent? byId(String? id) { + if (id == null || id.isEmpty) return null; + for (final accent in presets) { + if (accent.id == id) return accent; + } + return null; + } + + /// Minimum contrast an accent must have against white, so the app-wide + /// `Colors.white` foreground stays readable on top of it. See the class doc. + static const double minWhiteContrast = 3.8; + + /// Minimum contrast an accent must have against the *page*, so accent-coloured + /// text and icons stay readable on the background. + /// + /// Measured against true black, which is the harder of the two darkness + /// levels to sit on. 3.6 is just under the shipped red's 3.70 against the + /// ordinary #181818, so this floor never rejects anything the app already + /// ships. Without it a custom pick of near-black would satisfy the white rule + /// perfectly and then vanish — the splash wordmark, which is nothing but + /// accent on black, would be a blank screen. + static const double minBackgroundContrast = 3.6; + + /// Build a full triple from a colour the user picked on the wheel. + /// + /// [seed] is honoured in hue and saturation, but its lightness is pulled into + /// the band where the colour works in BOTH directions — white stays legible on + /// top of it, and it stays legible on the page. Picking pure yellow gives a + /// deep gold rather than an unreadable button; picking near-black gives a + /// visible dark tone rather than an invisible one. The step is small (0.2% + /// lightness) so the result stays as close to the pick as the contract allows. + factory AppAccent.custom(Color seed) { + final base = _enforceLegibility(seed); + return AppAccent( + id: customId, + base: base, + dark: _deriveDark(base), + light: _deriveLight(base), + isCustom: true, + ); + } + + /// Contrast ratio of [color] against pure white, per WCAG 2.1. + static double whiteContrast(Color color) => + 1.05 / (color.computeLuminance() + 0.05); + + /// Contrast ratio of [color] against pure black, per WCAG 2.1. + static double blackContrast(Color color) => + (color.computeLuminance() + 0.05) / 0.05; + + /// True when [color] is legible in both directions — see the class doc. + static bool isLegibleAccent(Color color) => + whiteContrast(color) >= minWhiteContrast && + blackContrast(color) >= minBackgroundContrast; + + /// Walk the seed's HSL lightness into the legible band. + /// + /// The band is never empty: the two rules put relative luminance between + /// 0.130 and 0.226, and luminance rises continuously with HSL lightness, so + /// every hue and saturation passes through it. The walk is one-directional — + /// too bright means darken, too dark means lighten — and bounded, so it + /// always terminates. + static Color _enforceLegibility(Color seed) { + final opaque = seed.withValues(alpha: 1.0); + if (isLegibleAccent(opaque)) return opaque; + + final hsl = HSLColor.fromColor(opaque); + final tooBright = whiteContrast(opaque) < minWhiteContrast; + final step = tooBright ? -0.002 : 0.002; + var lightness = hsl.lightness; + var best = opaque; + + for (var i = 0; i < 500; i++) { + final next = lightness + step; + if (next < 0 || next > 1) break; + lightness = next; + best = hsl.withLightness(lightness).toColor(); + if (isLegibleAccent(best)) return best; + } + return best; + } + + /// sRGB scale — the exact transform that takes #E50914 to #B20710. + static Color _deriveDark(Color base) => scaleChannels(base, 0.7773); + + /// Multiply every channel and snap the result back onto the 8-bit grid. + /// + /// The rounding is not cosmetic. Flutter's wide-gamut [Color] keeps channels + /// as doubles, so `0x14 * 0.7773` lands on 0.06097 rather than on `0x10`'s + /// 0.06275 — the same pixel once rasterised, but a different value to `==`. + /// Snapping keeps derived colours comparable, hashable and equal to the hex + /// codes this file documents. + static Color scaleChannels(Color base, double factor) => Color.fromARGB( + 255, + (base.r * 255 * factor).round().clamp(0, 255), + (base.g * 255 * factor).round().clamp(0, 255), + (base.b * 255 * factor).round().clamp(0, 255), + ); + + /// Lift 33.8% of the way to white with a small saturation boost — the exact + /// transform that takes #E50914 to #FF4B55. + static Color _deriveLight(Color base) { + final hsl = HSLColor.fromColor(base); + return hsl + .withSaturation((hsl.saturation * 1.0818).clamp(0.0, 1.0)) + .withLightness( + (hsl.lightness + (1.0 - hsl.lightness) * 0.3380).clamp(0.0, 1.0), + ) + .toColor(); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppAccent && + other.id == id && + other.base == base && + other.dark == dark && + other.light == light; + + @override + int get hashCode => Object.hash(id, base, dark, light); +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 5314413a..5cf77089 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -1,23 +1,94 @@ import 'package:flutter/material.dart'; +import 'app_palette.dart'; + +/// The app's colour vocabulary. +/// +/// ## Why some of these are getters +/// +/// Settings → Appearance lets the user change the accent colour and switch the +/// neutrals to true black (AMOLED). Both have to reach ~1900 existing call +/// sites across 140 files without any of them being rewritten to take a +/// `BuildContext`, so the members those two settings touch read through +/// [AppPalette.current] instead of being compile-time constants. +/// +/// Everything the settings do *not* touch is still `const`, both because it is +/// free and because it keeps `const` widget constructors working everywhere +/// they already did: +/// +/// * text colours — white-on-dark is right at every accent and both darkness +/// levels, so there is nothing to vary, +/// * [error] — must stay red even when the accent is green. Destructive +/// actions are not a place to express a theme. (It happens to equal the +/// default accent, which is why it was never visible as a separate colour +/// before; it is one now.) +/// * [success], [rating], the medals, and the fixed black/transparent +/// gradient ends. +/// +/// Reading a getter costs one static field load and one field read — no map +/// lookup, no `InheritedWidget`, nothing per-frame. What it does cost is that +/// `AppColors.primary` can no longer appear inside a `const` expression; drop +/// the `const` on the widget that holds it. class AppColors { AppColors._(); - static const Color background = Color(0xFF181818); + // ── Accent-driven ───────────────────────────────────────────────────────── + + static Color get primary => AppPalette.current.primary; + static Color get primaryDark => AppPalette.current.primaryDark; + static Color get primaryLight => AppPalette.current.primaryLight; + + /// Foreground for content painted on top of [primary]. Always white — see the + /// legibility contract on `AppAccent`, which is what makes that safe. + static Color get onPrimary => AppPalette.current.onPrimary; + + // ── Darkness-driven neutrals ────────────────────────────────────────────── + + static Color get background => AppPalette.current.background; + static Color get navBackground => AppPalette.current.navBackground; + static Color get surface => AppPalette.current.surface; + static Color get card => AppPalette.current.card; + static Color get surfaceVariant => AppPalette.current.surfaceVariant; + static Color get border => AppPalette.current.border; + static Color get divider => AppPalette.current.divider; + + /// The three stops of the accent-tinted page backdrop Profile paints. Follow + /// both the accent and the darkness level. + static Color get heroTop => AppPalette.current.heroTop; + static Color get heroMid => AppPalette.current.heroMid; + static Color get heroBottom => AppPalette.current.heroBottom; + + /// True while the user has AMOLED on. For the handful of places that need to + /// *behave* differently on true black rather than just be a shade darker. + static bool get isBlack => AppPalette.current.isBlack; + + /// True while the bottom bar's selected tab should carry the accent rather + /// than the shipped white. + static bool get isNavTinted => AppPalette.current.tintNav; + + // ── Fixed ───────────────────────────────────────────────────────────────── + + /// The splash has always been pure black, at every theme: it is the frame the + /// native launch screen hands over to, and a mismatch there is a visible + /// flash rather than a colour choice. static const Color splashBackground = Color(0xFF000000); - static const Color primary = Color(0xFFE50914); - static const Color primaryDark = Color(0xFFB20710); - static const Color primaryLight = Color(0xFFFF4B55); - static const Color surface = Color(0xFF242424); - static const Color surfaceVariant= Color(0xFF303030); - static const Color card = Color(0xFF282828); - static const Color navBackground = Color(0xFF0F0F0F); + static const Color textPrimary = Color(0xFFFFFFFF); static const Color textSecondary = Color(0xFFAAAAAA); static const Color textHint = Color(0xFF666666); - static const Color border = Color(0xFF3A3A3A); - static const Color divider = Color(0xFF2A2A2A); + + /// Deliberately NOT the accent. Kept at the original red so a destructive + /// action still reads as destructive under a green or blue accent. static const Color error = Color(0xFFE50914); + + /// The lifted partner to [error], for a rim or a label that has to sit on an + /// error fill without collapsing into it. + /// + /// It is exactly the old default [primaryLight]. Before the accent became a + /// setting, the "wrong answer" and "failed" states borrowed `primaryLight` + /// because it happened to be red; pointing them here keeps them pixel-identical + /// on the default theme while stopping them following a green accent. + static const Color errorLight = Color(0xFFFF4B55); static const Color success = Color(0xFF46D369); static const Color rating = Color(0xFFFFD700); diff --git a/lib/core/theme/app_palette.dart b/lib/core/theme/app_palette.dart new file mode 100644 index 00000000..038fab79 --- /dev/null +++ b/lib/core/theme/app_palette.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; + +import 'app_accent.dart'; + +/// How dark the neutral surfaces go. +/// +/// Deliberately an enum rather than a bool even though there are two values: +/// the setting is persisted as a name, and a third tier can be added later +/// without another migration. +enum AppDarkness { + /// The greys the app has always shipped. Default, and byte-identical to the + /// old hard-coded [AppColors] constants. + dark, + + /// Pure black background and near-black surfaces. On an OLED panel a black + /// pixel is an *off* pixel, so this is both the deepest contrast the app can + /// show and the cheapest to light. + black, +} + +/// The full set of colours the app repaints when Appearance changes. +/// +/// Resolved once per theme change and parked in [AppPalette.current], which +/// every `AppColors.*` getter reads. Nothing here is computed per frame. +/// +/// Only the neutrals and the accent live here. Text, error, success, rating and +/// the medal colours stay compile-time constants in `AppColors`, on purpose: +/// +/// * white-on-dark text is correct at every accent and both darkness levels, +/// * `error` must stay **red** even when the accent is green — an accent that +/// recoloured destructive actions would be a real bug, not a theme. +@immutable +class AppPalette { + const AppPalette._({ + required this.accent, + required this.darkness, + required this.tintNav, + required this.background, + required this.navBackground, + required this.surface, + required this.card, + required this.surfaceVariant, + required this.border, + required this.divider, + }); + + final AppAccent accent; + final AppDarkness darkness; + + /// Paint the bottom bar's selected tab in the accent instead of white. + /// + /// The stored *preference* ships on — see [AppConstants.tintNavKey]. This + /// parameter still defaults to false, because [resolve] is a pure function + /// and a caller that builds a palette by hand should get what it asked for + /// rather than a policy decision. + final bool tintNav; + + final Color background; + final Color navBackground; + final Color surface; + final Color card; + final Color surfaceVariant; + final Color border; + final Color divider; + + Color get primary => accent.base; + Color get primaryDark => accent.dark; + Color get primaryLight => accent.light; + + /// Foreground for anything painted **on** [primary]. + /// + /// Always white, and that is a guarantee rather than a coincidence: every + /// accent — presets and custom picks alike — is held at or above + /// [AppAccent.minWhiteContrast] against white precisely so the ~140 places + /// that hard-code `Colors.white` on an accent fill stay correct. See + /// [AppAccent]'s legibility contract. + Color get onPrimary => Colors.white; + + bool get isBlack => darkness == AppDarkness.black; + + // ── Neutral ramps ───────────────────────────────────────────────────────── + // + // `dark` is the shipped palette, unchanged. `black` keeps the *same ordering* + // (nav ≤ background < surface < card < surfaceVariant) so every screen's + // depth cues survive — it just compresses the whole ramp toward zero and + // pulls background and nav all the way to true black. + + static const Color _darkBackground = Color(0xFF181818); + static const Color _darkNavBackground = Color(0xFF0F0F0F); + static const Color _darkSurface = Color(0xFF242424); + static const Color _darkCard = Color(0xFF282828); + static const Color _darkSurfaceVariant = Color(0xFF303030); + static const Color _darkBorder = Color(0xFF3A3A3A); + static const Color _darkDivider = Color(0xFF2A2A2A); + + static const Color _blackBackground = Color(0xFF000000); + static const Color _blackNavBackground = Color(0xFF000000); + static const Color _blackSurface = Color(0xFF0E0E0E); + static const Color _blackCard = Color(0xFF141414); + static const Color _blackSurfaceVariant = Color(0xFF1E1E1E); + + /// Borders and dividers are *raised* relative to the rest of the ramp under + /// AMOLED. On a #181818 background a #3A3A3A hairline separates by luminance + /// difference; on true black the same job needs a line that is still clearly + /// above its surface, or every card edge disappears. + static const Color _blackBorder = Color(0xFF2B2B2B); + static const Color _blackDivider = Color(0xFF1A1A1A); + + /// The palette in force. Swapped by `ThemeController`, read by every + /// `AppColors` getter. Starts at the shipped defaults so anything that + /// touches a colour before the controller has loaded — an early error screen, + /// a widget test with no DI — still paints the app the app has always been. + static AppPalette current = resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + + static AppPalette resolve({ + required AppAccent accent, + required AppDarkness darkness, + bool tintNav = false, + }) { + final black = darkness == AppDarkness.black; + return AppPalette._( + accent: accent, + darkness: darkness, + tintNav: tintNav, + background: black ? _blackBackground : _darkBackground, + navBackground: black ? _blackNavBackground : _darkNavBackground, + surface: black ? _blackSurface : _darkSurface, + card: black ? _blackCard : _darkCard, + surfaceVariant: black ? _blackSurfaceVariant : _darkSurfaceVariant, + border: black ? _blackBorder : _darkBorder, + divider: black ? _blackDivider : _darkDivider, + ); + } + + // ── Accent-tinted page backdrop ─────────────────────────────────────────── + // + // Profile paints a three-stop vertical gradient behind its content. It used + // to be the literal list [#1E1416, #181818, #101010] — a red-tinted top over + // the dark grey — which silently stayed red no matter what accent was chosen. + // These three getters reproduce that gradient exactly for the default accent + // and follow the chosen one otherwise. + + /// Top stop: the background nudged onto the accent's hue. + /// + /// At the default red this evaluates to #1E1414 — the shipped #1E1416 to + /// within one step of blue. Under AMOLED it is a far deeper whisper of accent + /// (#0B0404 at red) so the header still reads as *this* app's header without + /// giving up the black. + Color get heroTop { + final hue = HSLColor.fromColor(primary).hue; + final base = HSLColor.fromColor(background); + return HSLColor.fromAHSL( + 1, + hue, + isBlack ? 0.50 : 0.20, + (base.lightness + (isBlack ? 0.030 : 0.004)).clamp(0.0, 1.0), + ).toColor(); + } + + /// Middle stop: the plain background, so the gradient lands on the same + /// colour every other screen uses. + Color get heroMid => background; + + /// Bottom stop: the background scaled to 0.667 in sRGB — exactly the + /// #181818 → #101010 fall-off the gradient always had. Under AMOLED the + /// background is already zero, so this stays true black. + Color get heroBottom => AppAccent.scaleChannels(background, 0.667); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppPalette && + other.accent == accent && + other.darkness == darkness && + other.tintNav == tintNav; + + @override + int get hashCode => Object.hash(accent, darkness, tintNav); +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 034e5ea0..22f7f99d 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -2,9 +2,28 @@ import 'package:flutter/material.dart'; import '../system/platform_utils.dart'; import 'app_colors.dart'; +/// The app's one button height and one corner radius. +/// +/// Named rather than repeated so a screen that has to build its own control by +/// hand — a gradient CTA, a custom pill — lands on the same grid as the themed +/// buttons beside it instead of being eyeballed. +const double kButtonHeight = 52; +const double kButtonRadius = 10; + +/// Fields, cards, sheets and snackbars — everything that is a *surface* rather +/// than a control. Same 10 as the buttons, so a form reads as one object. +const double kFieldRadius = 10; + class AppTheme { AppTheme._(); + /// Built fresh on every read, from whatever `AppColors` currently resolves to. + /// + /// That is cheap (one `ThemeData` allocation) and it is what lets Appearance + /// change the accent or switch to true black without a restart: `MyApp` reads + /// this again on each rebuild, so the Material component defaults follow the + /// palette in exactly the same breath as the widgets that read `AppColors` + /// directly. static ThemeData get dark => ThemeData( brightness: Brightness.dark, useMaterial3: true, @@ -12,15 +31,20 @@ class AppTheme { colorScheme: ColorScheme( brightness: Brightness.dark, primary: AppColors.primary, - onPrimary: Colors.white, + onPrimary: AppColors.onPrimary, secondary: AppColors.primary, - onSecondary: Colors.white, + onSecondary: AppColors.onPrimary, error: AppColors.error, onError: Colors.white, surface: AppColors.surface, onSurface: AppColors.textPrimary, surfaceContainerHighest: AppColors.surfaceVariant, outline: AppColors.border, + // Left unset, M3 resolves outlineVariant to onSurface — i.e. white — + // and every component that draws its own hairline (TabBar, Divider, + // ListTile separators) paints it near-white. Harmless-looking on + // #181818, glaring on true black. + outlineVariant: AppColors.divider, ), appBarTheme: const AppBarTheme( backgroundColor: Colors.transparent, @@ -112,22 +136,31 @@ class AppTheme { elevation: 0, margin: EdgeInsets.zero, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), ), ), + // ── The one button shape ──────────────────────────────────────── + // + // 52 tall, 10 radius, 15.5/w700 label. These are the numbers the + // onboarding and auth screens were already using through their own + // `AuthPrimaryButton`, and they are here now so every ElevatedButton and + // FilledButton in the app is that button — no screen has to opt in, and + // none can drift. elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, - foregroundColor: Colors.white, + foregroundColor: AppColors.onPrimary, + disabledBackgroundColor: AppColors.surfaceVariant, + disabledForegroundColor: AppColors.textHint, elevation: 0, - minimumSize: const Size(double.infinity, 50), + minimumSize: const Size(double.infinity, kButtonHeight), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kButtonRadius), ), textStyle: const TextStyle( - fontSize: 16, + fontSize: 15.5, fontWeight: FontWeight.w700, - letterSpacing: 0.5, + letterSpacing: 0.2, ), ), ), @@ -137,15 +170,18 @@ class AppTheme { filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( backgroundColor: AppColors.primary, - foregroundColor: Colors.white, + foregroundColor: AppColors.onPrimary, + disabledBackgroundColor: AppColors.surfaceVariant, + disabledForegroundColor: AppColors.textHint, elevation: 0, + minimumSize: const Size(0, kButtonHeight), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kButtonRadius), ), textStyle: const TextStyle( - fontSize: 16, + fontSize: 15.5, fontWeight: FontWeight.w700, - letterSpacing: 0.5, + letterSpacing: 0.2, ), ), ), @@ -153,13 +189,14 @@ class AppTheme { style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, side: const BorderSide(color: AppColors.textSecondary, width: 1.5), - minimumSize: const Size(double.infinity, 50), + minimumSize: const Size(double.infinity, kButtonHeight), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kButtonRadius), ), textStyle: const TextStyle( - fontSize: 16, + fontSize: 15.5, fontWeight: FontWeight.w600, + letterSpacing: 0.2, ), ), ), @@ -180,23 +217,23 @@ class AppTheme { vertical: 14, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), - borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + borderRadius: BorderRadius.circular(kFieldRadius), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), ), errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), borderSide: const BorderSide(color: AppColors.error, width: 1.5), ), focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), borderSide: const BorderSide(color: AppColors.error, width: 1.5), ), hintStyle: const TextStyle(color: AppColors.textHint, fontSize: 14), @@ -219,7 +256,7 @@ class AppTheme { ), navigationBarTheme: NavigationBarThemeData( backgroundColor: AppColors.background, - indicatorColor: Color(0x33E50914), + indicatorColor: AppColors.primary.withValues(alpha: 0.2), elevation: 0, height: 64, labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, @@ -229,10 +266,10 @@ class AppTheme { selectedColor: AppColors.primary, disabledColor: AppColors.surfaceVariant, labelStyle: const TextStyle(color: AppColors.textPrimary, fontSize: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: BorderSide.none, ), - progressIndicatorTheme: const ProgressIndicatorThemeData( + progressIndicatorTheme: ProgressIndicatorThemeData( color: AppColors.primary, ), switchTheme: SwitchThemeData( @@ -251,13 +288,13 @@ class AppTheme { activeTrackColor: AppColors.primary, inactiveTrackColor: AppColors.surfaceVariant, thumbColor: AppColors.primary, - overlayColor: Color(0x33E50914), + overlayColor: AppColors.primary.withValues(alpha: 0.2), ), snackBarTheme: SnackBarThemeData( backgroundColor: AppColors.surfaceVariant, contentTextStyle: const TextStyle(color: AppColors.textPrimary), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(isDesktopPlatform ? 10 : 4), + borderRadius: BorderRadius.circular(kFieldRadius), ), behavior: SnackBarBehavior.floating, elevation: isDesktopPlatform ? 6 : null, @@ -304,7 +341,7 @@ class AppTheme { return AppColors.textSecondary; }), ), - tabBarTheme: const TabBarThemeData( + tabBarTheme: TabBarThemeData( labelColor: AppColors.textPrimary, unselectedLabelColor: AppColors.textHint, indicatorColor: AppColors.primary, @@ -319,7 +356,7 @@ class AppTheme { tooltipTheme: TooltipThemeData( decoration: BoxDecoration( color: AppColors.surfaceVariant, - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(8), ), textStyle: const TextStyle(color: AppColors.textPrimary, fontSize: 12), ), diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 48f6d6dd..a43676fe 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,17 +1,155 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'app_accent.dart'; +import 'app_palette.dart'; + +/// Owns everything Settings → Appearance can change: the accent colour and how +/// dark the neutrals go. +/// +/// ## How a change reaches the whole app +/// +/// Almost every widget in Sozo reads a colour as `AppColors.primary` — a static +/// getter, with no `BuildContext` and therefore no `InheritedWidget` to depend +/// on. So a theme change is two separate jobs, and this class does the first: +/// +/// 1. **here** — swap [AppPalette.current], persist, then `notifyListeners()`, +/// 2. **`MyApp`** — hand `MaterialApp` a freshly built [ThemeData] *and* mark +/// every element in the tree dirty, so the static readers repaint too. +/// +/// Splitting it that way is what keeps the change instant and total: no restart, +/// no route rebuild, no lost navigation stack or scroll position. +/// +/// The constructor loads and applies synchronously, before `runApp`, so the very +/// first frame is already painted in the user's colours — there is never a flash +/// of red before a blue theme appears. class ThemeController extends ChangeNotifier { + ThemeController(this._hive) { + _accent = _readAccent(); + _darkness = _hive.isAmoledMode ? AppDarkness.black : AppDarkness.dark; + _tintNav = _hive.isNavTinted; + _apply(); + } + final HiveService _hive; - ThemeController(this._hive) : _amoled = _hive.isAmoledMode; + late AppAccent _accent; + late AppDarkness _darkness; + late bool _tintNav; + + AppAccent get accent => _accent; + AppDarkness get darkness => _darkness; + + /// Whether the bottom bar's selected tab carries the accent. + bool get isNavTinted => _tintNav; + + /// True while the neutrals are true black. + bool get isAmoled => _darkness == AppDarkness.black; + + /// The id to tick in the swatch row. For a custom colour this is + /// [AppAccent.customId], not the nearest preset. + String get accentId => _accent.id; + + /// Everything is at its shipped value — what the "Reset" action undoes. + /// + /// The tab-bar tint ships **on**, so "default" means on here. + bool get isDefault => + _accent.id == AppAccent.fallback.id && + _darkness == AppDarkness.dark && + _tintNav; + + AppAccent _readAccent() { + final id = _hive.accentId; + if (id == AppAccent.customId) { + final argb = _hive.customAccentArgb; + // A stored custom id with no stored colour is a half-written preference; + // fall back rather than paint an arbitrary colour. + if (argb != null) return AppAccent.custom(Color(argb)); + return AppAccent.fallback; + } + return AppAccent.byId(id) ?? AppAccent.fallback; + } + + void _apply() { + AppPalette.current = AppPalette.resolve( + accent: _accent, + darkness: _darkness, + tintNav: _tintNav, + ); + } + + /// Pick one of the [AppAccent.presets]. + Future setAccent(AppAccent accent) async { + if (accent.id == _accent.id && accent.base == _accent.base) return; + _accent = accent; + _apply(); + notifyListeners(); + await _hive.setAccentId(accent.id); + } + + /// Pick an arbitrary colour. [seed] is normalised by [AppAccent.custom] so + /// white stays legible on it; the *seed* is what gets persisted, so re-opening + /// the picker shows the wheel where the user left it rather than where the + /// contract moved it. + Future setCustomAccent(Color seed) async { + final accent = AppAccent.custom(seed); + _accent = accent; + _apply(); + notifyListeners(); + await _hive.setCustomAccentArgb(seed.withValues(alpha: 1.0).toARGB32()); + await _hive.setAccentId(AppAccent.customId); + } + + /// The seed behind a custom accent, for re-opening the picker on the colour + /// the user actually chose. Null unless the current accent is custom. + Color? get customSeed { + if (!_accent.isCustom) return null; + final argb = _hive.customAccentArgb; + return argb == null ? null : Color(argb); + } + + Future setAmoled(bool enabled) async { + final next = enabled ? AppDarkness.black : AppDarkness.dark; + if (next == _darkness) return; + _darkness = next; + _apply(); + notifyListeners(); + await _hive.setAmoledMode(enabled); + } - bool _amoled; - bool get isAmoled => _amoled; + Future toggleAmoled() => setAmoled(!isAmoled); + + Future setNavTinted(bool enabled) async { + if (enabled == _tintNav) return; + _tintNav = enabled; + _apply(); + notifyListeners(); + await _hive.setNavTinted(enabled); + } + + /// Jump to a preset the user is not already on. + /// + /// Deterministic rather than random: [step] walks the ring, so tapping it + /// repeatedly tours the palette instead of landing on the same colour twice + /// in a row the way `Random` eventually does. + Future cycleAccent({int step = 1}) async { + final presets = AppAccent.presets; + final at = presets.indexWhere((a) => a.id == _accent.id); + // A custom accent is not on the ring, so stepping starts from the default. + final next = ((at < 0 ? -1 : at) + step) % presets.length; + await setAccent(presets[next < 0 ? next + presets.length : next]); + } - Future toggle() async { - _amoled = !_amoled; - await _hive.setAmoledMode(_amoled); + /// Back to the shipped look: default red, ordinary dark greys. + Future reset() async { + if (isDefault) return; + _accent = AppAccent.fallback; + _darkness = AppDarkness.dark; + _tintNav = true; + _apply(); notifyListeners(); + await _hive.setAccentId(AppAccent.fallback.id); + await _hive.setAmoledMode(false); + await _hive.setNavTinted(true); } } diff --git a/lib/core/widgets/app_buttons.dart b/lib/core/widgets/app_buttons.dart new file mode 100644 index 00000000..d5fd604e --- /dev/null +++ b/lib/core/widgets/app_buttons.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; + +/// The app's primary call to action. +/// +/// This is the button the onboarding and auth screens always used, lifted out +/// of `features/auth` so the rest of the app can use the same one instead of +/// hand-rolling a container with a colour and a radius. Its metrics come from +/// [kButtonHeight] / [kButtonRadius] and the shared `elevatedButtonTheme`, so +/// it and every plain `ElevatedButton` in the app are the same object. +/// +/// [loading] swaps the label for a spinner *without* changing the button's size, +/// so a form does not jump when it is submitted. +class AppPrimaryButton extends StatelessWidget { + const AppPrimaryButton({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + this.icon, + this.expand = true, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + final IconData? icon; + + /// Full width (a form's submit) vs. hugging its label (a row of actions). + final bool expand; + + @override + Widget build(BuildContext context) { + final glyph = icon; + final child = loading + ? SizedBox( + width: 21, + height: 21, + child: CircularProgressIndicator( + color: AppColors.onPrimary, + strokeWidth: 2.2, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (glyph != null) ...[ + Icon(glyph, size: 19), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + + return SizedBox( + height: kButtonHeight, + width: expand ? double.infinity : null, + child: ElevatedButton( + onPressed: loading ? null : onPressed, + style: expand + ? null + : ElevatedButton.styleFrom( + minimumSize: const Size(0, kButtonHeight), + padding: const EdgeInsets.symmetric(horizontal: 20), + ), + child: child, + ), + ); + } +} + +/// The quieter partner to [AppPrimaryButton] — same height and radius, an +/// outline instead of a fill. For the "not now" beside a "continue". +class AppSecondaryButton extends StatelessWidget { + const AppSecondaryButton({ + super.key, + required this.label, + required this.onPressed, + this.icon, + this.expand = true, + }); + + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final bool expand; + + @override + Widget build(BuildContext context) { + final glyph = icon; + return SizedBox( + height: kButtonHeight, + width: expand ? double.infinity : null, + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, kButtonHeight), + padding: EdgeInsets.symmetric(horizontal: expand ? 16 : 20), + side: BorderSide( + color: AppColors.textPrimary.withValues(alpha: 0.22), + width: 1.2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (glyph != null) ...[ + Icon(glyph, size: 19), + const SizedBox(width: 8), + ], + Flexible( + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/app_tab_bar.dart b/lib/core/widgets/app_tab_bar.dart index a11a3fd1..790f4bdf 100644 --- a/lib/core/widgets/app_tab_bar.dart +++ b/lib/core/widgets/app_tab_bar.dart @@ -16,7 +16,7 @@ class AppTabBar extends StatefulWidget implements PreferredSizeWidget { this.controller, this.isScrollable = true, this.showDivider = true, - this.background = AppColors.background, + this.background, this.padding = const EdgeInsets.only(left: 8), }); @@ -26,7 +26,10 @@ class AppTabBar extends StatefulWidget implements PreferredSizeWidget { final TabController? controller; final bool isScrollable; final bool showDivider; - final Color background; + /// Defaults to [AppColors.background]. Nullable rather than defaulted in the + /// constructor because the palette is a runtime value now, and a default + /// parameter has to be a compile-time constant. + final Color? background; final EdgeInsets padding; static const double _dividerHeight = 0.5; @@ -110,7 +113,7 @@ class _AppTabBarState extends State @override Widget build(BuildContext context) { return Container( - color: widget.background, + color: widget.background ?? AppColors.background, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/anilist/data/anilist_api.dart b/lib/features/anilist/data/anilist_api.dart index fefe8efb..18ef5925 100644 --- a/lib/features/anilist/data/anilist_api.dart +++ b/lib/features/anilist/data/anilist_api.dart @@ -35,6 +35,16 @@ class AnilistApi { static const String _rateLimitMessage = 'AniList is rate limiting requests'; + /// AniList refusing everything, and the reason it gave. + /// + /// Separate from [_throttledUntil]: a rate limit is our fault and clears in + /// seconds, an outage is theirs and lasts as long as it lasts. Keeping them + /// apart means we replay AniList's own words instead of telling the user to + /// slow down when slowing down cannot help. + DateTime? _outageUntil; + String? _outageMessage; + static const Duration _outageBackoff = Duration(minutes: 5); + static Duration _retryAfter(Response? response) { final headers = response?.headers; final retryAfter = int.tryParse(headers?.value('retry-after') ?? ''); @@ -100,6 +110,13 @@ class AnilistApi { if (until != null && DateTime.now().isBefore(until)) { throw const AnilistException(_rateLimitMessage, rateLimited: true); } + // While AniList is refusing everything there is nothing to gain by asking + // again — and the calendar alone fires two requests per visit (the selected + // day plus tomorrow's prefetch), every one of them a guaranteed failure. + final outage = _outageUntil; + if (outage != null && DateTime.now().isBefore(outage)) { + throw AnilistException(_outageMessage ?? 'AniList is unavailable'); + } final Response response; try { @@ -115,27 +132,57 @@ class AnilistApi { _throttledUntil = DateTime.now().add(_retryAfter(e.response)); throw const AnilistException(_rateLimitMessage, rateLimited: true); } + // GraphQL errors normally arrive in a 200 body and are read below, but a + // refusal comes back as a non-2xx — which Dio throws on, so the body was + // discarded and the reason with it. When AniList disabled its API it + // answered every query with 403 and "The AniList API has been temporarily + // disabled due to severe stability issues", and every screen showed its + // own generic "could not load" beside a Try again that could not work. + final message = _graphqlError(e.response?.data); + if (message != null) { + // Back off only when the service is refusing everyone — 403, or a 5xx. + // A 400 means we sent something wrong, and muting our own bug for five + // minutes would hide it rather than fix it, so that one is reported and + // the next call still goes out. + final code = e.response?.statusCode ?? 0; + if (code == 403 || code >= 500) { + _outageUntil = DateTime.now().add(_outageBackoff); + _outageMessage = message; + } + throw AnilistException(message); + } rethrow; } _throttledUntil = null; + _outageUntil = null; + _outageMessage = null; final body = response.data; if (body is! Map) throw const AnilistException('Unexpected AniList reply'); // GraphQL reports failures in a 200 body, so a non-throwing Dio call is not // the same as a successful query. - final errors = body['errors']; - if (errors is List && errors.isNotEmpty) { - final first = errors.first; - final message = first is Map ? first['message']?.toString() : null; - throw AnilistException(message ?? 'AniList rejected the request'); - } + final message = _graphqlError(body); + if (message != null) throw AnilistException(message); final data = body['data']; if (data is! Map) throw const AnilistException('AniList returned no data'); return data.cast(); } + /// The first message out of a GraphQL `errors` array, wherever it arrives — + /// a 200 body or the body of a refusal. Null when the payload carries none. + static String? _graphqlError(dynamic body) { + if (body is! Map) return null; + final errors = body['errors']; + if (errors is! List || errors.isEmpty) return null; + final first = errors.first; + final message = first is Map ? first['message']?.toString() : null; + return (message != null && message.isNotEmpty) + ? message + : 'AniList rejected the request'; + } + /// Who the stored token belongs to. Also the cheapest way to tell whether a /// token is still valid — AniList tokens last about a year but can be revoked. Future viewer(String token) async { diff --git a/lib/features/anilist/presentation/pages/airing_calendar_page.dart b/lib/features/anilist/presentation/pages/airing_calendar_page.dart index 20d23f2b..254f8254 100644 --- a/lib/features/anilist/presentation/pages/airing_calendar_page.dart +++ b/lib/features/anilist/presentation/pages/airing_calendar_page.dart @@ -1012,7 +1012,7 @@ class _MediaActions extends StatelessWidget { return SafeArea( top: false, child: Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), @@ -1056,7 +1056,7 @@ class _MediaActions extends StatelessWidget { ], ), const SizedBox(height: 12), - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), _SheetAction( icon: Icons.travel_explore_rounded, label: 'anilist.find_in_sources'.tr(), diff --git a/lib/features/anilist/presentation/pages/anilist_browse_page.dart b/lib/features/anilist/presentation/pages/anilist_browse_page.dart index 2f72b741..d1992174 100644 --- a/lib/features/anilist/presentation/pages/anilist_browse_page.dart +++ b/lib/features/anilist/presentation/pages/anilist_browse_page.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/anilist/data/anilist_api.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/domain/entities/anilist_entities.dart'; @@ -458,7 +459,7 @@ class _MediaSheet extends StatelessWidget { style: OutlinedButton.styleFrom( padding: EdgeInsets.zero, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: const Icon(Icons.expand_more_rounded, size: 20), diff --git a/lib/features/anilist/presentation/pages/anilist_library_page.dart b/lib/features/anilist/presentation/pages/anilist_library_page.dart index f67ae4b4..acd185bf 100644 --- a/lib/features/anilist/presentation/pages/anilist_library_page.dart +++ b/lib/features/anilist/presentation/pages/anilist_library_page.dart @@ -168,7 +168,7 @@ class _StatusTabBar extends StatelessWidget { Widget build(BuildContext context) { return Container( alignment: Alignment.centerLeft, - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border(bottom: BorderSide(color: AppColors.divider, width: 0.5)), ), child: TabBar( diff --git a/lib/features/anilist/presentation/pages/connections_page.dart b/lib/features/anilist/presentation/pages/connections_page.dart index 4d4d0ad6..d0b929ec 100644 --- a/lib/features/anilist/presentation/pages/connections_page.dart +++ b/lib/features/anilist/presentation/pages/connections_page.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/anilist/data/anilist_link_store.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; @@ -399,10 +400,10 @@ class _TrackerCard extends StatelessWidget { onPressed: onDisconnect, style: OutlinedButton.styleFrom( foregroundColor: AppColors.error, - side: const BorderSide(color: AppColors.border), + side: BorderSide(color: AppColors.border), padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.link_off_rounded, size: 18), @@ -415,7 +416,7 @@ class _TrackerCard extends StatelessWidget { foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.open_in_new_rounded, size: 18), diff --git a/lib/features/anilist/presentation/widgets/anilist_brand.dart b/lib/features/anilist/presentation/widgets/anilist_brand.dart index b98f4c16..fab90b69 100644 --- a/lib/features/anilist/presentation/widgets/anilist_brand.dart +++ b/lib/features/anilist/presentation/widgets/anilist_brand.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; /// AniList's own blue. Used only for AniList affordances, so a "connect" or @@ -187,7 +188,7 @@ class AnilistStateMessage extends StatelessWidget { minimumSize: const Size(0, 42), padding: const EdgeInsets.symmetric(horizontal: 20), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text(actionLabel!), @@ -281,7 +282,7 @@ class AnilistConnectPrompt extends StatelessWidget { foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: busy diff --git a/lib/features/anilist/presentation/widgets/anilist_entry_sheet.dart b/lib/features/anilist/presentation/widgets/anilist_entry_sheet.dart index 157b51cf..ea5230d2 100644 --- a/lib/features/anilist/presentation/widgets/anilist_entry_sheet.dart +++ b/lib/features/anilist/presentation/widgets/anilist_entry_sheet.dart @@ -138,7 +138,7 @@ class _AnilistEntrySheetState extends State { return SafeArea( top: false, child: Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), @@ -247,7 +247,7 @@ class _AnilistEntrySheetState extends State { ], ), const SizedBox(height: 20), - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), const SizedBox(height: 8), _Action( icon: Icons.travel_explore_rounded, diff --git a/lib/features/anilist/presentation/widgets/anilist_link_sheet.dart b/lib/features/anilist/presentation/widgets/anilist_link_sheet.dart index 1876e611..123b532f 100644 --- a/lib/features/anilist/presentation/widgets/anilist_link_sheet.dart +++ b/lib/features/anilist/presentation/widgets/anilist_link_sheet.dart @@ -170,7 +170,7 @@ class _AnilistLinkSheetState extends State { maxChildSize: 0.95, expand: false, builder: (context, scrollController) => Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), diff --git a/lib/features/app_lock/presentation/pages/app_lock_settings_page.dart b/lib/features/app_lock/presentation/pages/app_lock_settings_page.dart index 050d8a77..eac6a72f 100644 --- a/lib/features/app_lock/presentation/pages/app_lock_settings_page.dart +++ b/lib/features/app_lock/presentation/pages/app_lock_settings_page.dart @@ -131,7 +131,7 @@ class _AppLockSettingsPageState extends State { ), ), if (enabled) ...[ - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), _Row( icon: Icons.pin_rounded, title: 'app_lock.change_pin'.tr(), @@ -143,7 +143,7 @@ class _AppLockSettingsPageState extends State { onTap: _changePin, ), if (_biometricAvailable) ...[ - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), _Row( icon: Icons.fingerprint_rounded, title: 'app_lock.biometric'.tr(), @@ -155,7 +155,7 @@ class _AppLockSettingsPageState extends State { ), ], ], - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), Opacity( opacity: enabled ? 1.0 : 0.5, child: _Row( diff --git a/lib/features/app_lock/presentation/pages/pin_verify_page.dart b/lib/features/app_lock/presentation/pages/pin_verify_page.dart index 727d6c9c..5e06b0dd 100644 --- a/lib/features/app_lock/presentation/pages/pin_verify_page.dart +++ b/lib/features/app_lock/presentation/pages/pin_verify_page.dart @@ -69,8 +69,10 @@ class _PinVerifyViewState extends State<_PinVerifyView> { onPressed: () => Navigator.of(dctx).pop(true), child: Text( 'app_lock.reset'.tr(), + // This wipes the user's PIN — the same weight of action as the + // app's other error-coloured confirmations. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontWeight: FontWeight.w700, ), ), @@ -126,7 +128,7 @@ class _PinVerifyViewState extends State<_PinVerifyView> { color: AppColors.primary.withValues(alpha: 0.12), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.lock_rounded, color: AppColors.primary, size: 30, diff --git a/lib/features/app_updater/presentation/pages/force_update_page.dart b/lib/features/app_updater/presentation/pages/force_update_page.dart index 2d956562..8131aa63 100644 --- a/lib/features/app_updater/presentation/pages/force_update_page.dart +++ b/lib/features/app_updater/presentation/pages/force_update_page.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/app_updater/domain/entities/app_version_check.dart'; import 'package:soplay/features/app_updater/presentation/widgets/release_notes_view.dart'; @@ -92,7 +93,7 @@ class ForceUpdatePage extends StatelessWidget { backgroundColor: accent, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: () => onUpdate(context), diff --git a/lib/features/app_updater/presentation/widgets/release_notes_view.dart b/lib/features/app_updater/presentation/widgets/release_notes_view.dart index 0e25f6fe..1082fcdc 100644 --- a/lib/features/app_updater/presentation/widgets/release_notes_view.dart +++ b/lib/features/app_updater/presentation/widgets/release_notes_view.dart @@ -95,7 +95,7 @@ class ReleaseNotesView extends StatelessWidget { 'blockquote': Style( margin: Margins.only(bottom: 8), padding: HtmlPaddings.only(left: 10), - border: const Border( + border: Border( left: BorderSide(color: AppColors.border, width: 3), ), color: AppColors.textSecondary, diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index d4cf9f8b..3c86030d 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -386,6 +386,14 @@ class AuthBloc extends Bloc { /// tests where the sync service is not wired. Future _syncHistory() async { if (!getIt.isRegistered()) return; + // Before anything is uploaded: the rows sitting on this phone may belong to + // whoever signed in last. Signing out resets the push watermark, so the + // first sync pushes EVERYTHING local — which is what you want when the same + // person returns, and a data leak when it is somebody else. + final userId = hiveService.getUser()?.id; + if (userId != null && userId.isNotEmpty) { + await getIt().adoptFor(userId); + } await getIt().sync(); // The AniList link is stored on the account, so signing in on a new device // is exactly when it should reappear — without this, connecting on the diff --git a/lib/features/auth/presentation/pages/otp_verify_page.dart b/lib/features/auth/presentation/pages/otp_verify_page.dart index d473ae33..00437321 100644 --- a/lib/features/auth/presentation/pages/otp_verify_page.dart +++ b/lib/features/auth/presentation/pages/otp_verify_page.dart @@ -206,7 +206,7 @@ class _OtpVerifyPageState extends State ), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.mark_email_read_rounded, color: AppColors.primary, size: 40, diff --git a/lib/features/auth/presentation/widgets/auth_widgets.dart b/lib/features/auth/presentation/widgets/auth_widgets.dart index 493e5cbc..7e804b69 100644 --- a/lib/features/auth/presentation/widgets/auth_widgets.dart +++ b/lib/features/auth/presentation/widgets/auth_widgets.dart @@ -2,7 +2,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:soplay/core/widgets/app_buttons.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/onboarding/presentation/widgets/poster_wall.dart'; /// The shell every auth screen sits in: a living poster header that dissolves @@ -142,7 +144,7 @@ class _AuthTopBar extends StatelessWidget { const SizedBox(width: 12), Text( 'app_name'.tr(), - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 22, fontWeight: FontWeight.w900, @@ -333,6 +335,12 @@ class AuthErrorBanner extends StatelessWidget { /// The one call-to-action on each screen, with the spinner built in so no page /// re-invents the swap between label and progress. +/// The auth screens' name for [AppPrimaryButton]. +/// +/// Kept as a thin alias rather than deleted: its metrics became the app-wide +/// button in `app_theme.dart`, so there is now exactly one definition of what a +/// primary button is, and the dozen auth/onboarding call sites did not have to +/// be rewritten to say so. class AuthPrimaryButton extends StatelessWidget { const AuthPrimaryButton({ super.key, @@ -346,35 +354,11 @@ class AuthPrimaryButton extends StatelessWidget { final bool loading; @override - Widget build(BuildContext context) { - return SizedBox( - height: 52, - child: ElevatedButton( - onPressed: loading ? null : onPressed, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: loading - ? const SizedBox( - width: 21, - height: 21, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.2, - ), - ) - : Text( - label, - style: const TextStyle( - fontSize: 15.5, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - } + Widget build(BuildContext context) => AppPrimaryButton( + label: label, + onPressed: onPressed, + loading: loading, + ); } class AuthDivider extends StatelessWidget { @@ -384,7 +368,7 @@ class AuthDivider extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - const Expanded(child: Divider(color: AppColors.border, height: 1)), + Expanded(child: Divider(color: AppColors.border, height: 1)), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( @@ -392,7 +376,7 @@ class AuthDivider extends StatelessWidget { style: const TextStyle(color: AppColors.textHint, fontSize: 12.5), ), ), - const Expanded(child: Divider(color: AppColors.border, height: 1)), + Expanded(child: Divider(color: AppColors.border, height: 1)), ], ); } @@ -422,7 +406,7 @@ class GoogleAuthButton extends StatelessWidget { disabledBackgroundColor: Colors.white24, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: loading @@ -478,7 +462,7 @@ class AuthSwitchPrompt extends StatelessWidget { onPressed: onTap, child: Text( action, - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontWeight: FontWeight.w700, ), diff --git a/lib/features/banners/presentation/widgets/banners_carousel.dart b/lib/features/banners/presentation/widgets/banners_carousel.dart index 13801749..18b065fe 100644 --- a/lib/features/banners/presentation/widgets/banners_carousel.dart +++ b/lib/features/banners/presentation/widgets/banners_carousel.dart @@ -123,7 +123,7 @@ class _BannerCard extends StatelessWidget { CachedNetworkImage( imageUrl: item.imageUrl, fit: BoxFit.cover, - errorWidget: (_, _, _) => const ColoredBox( + errorWidget: (_, _, _) => ColoredBox( color: AppColors.surfaceVariant, ), ), diff --git a/lib/features/cloudflare/cloudflare_solver_page.dart b/lib/features/cloudflare/cloudflare_solver_page.dart index be6ae3b5..05088600 100644 --- a/lib/features/cloudflare/cloudflare_solver_page.dart +++ b/lib/features/cloudflare/cloudflare_solver_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:soplay/core/system/webview_env.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; class CloudflareSolverPage extends StatefulWidget { const CloudflareSolverPage({ @@ -125,7 +126,7 @@ class _CloudflareSolverPageState extends State { }, ), if (!_firstLoadDone) - const ColoredBox( + ColoredBox( color: AppColors.background, child: Center( child: CircularProgressIndicator( @@ -186,7 +187,7 @@ class _CloudflareSolverPageState extends State { foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: _onManualDone, diff --git a/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart b/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart index 09f8faf4..1cc1d889 100644 --- a/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart +++ b/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart @@ -312,7 +312,7 @@ class _CloudStreamPluginsPageState extends State { maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 12.5), ), ), @@ -405,7 +405,7 @@ class _CloudStreamPluginsPageState extends State { color: AppColors.primary.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), - child: const Icon(Icons.extension_rounded, + child: Icon(Icons.extension_rounded, color: AppColors.primary, size: 18), ); if (url == null || url.isEmpty) return fallback(); diff --git a/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart b/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart index 9469657a..09899a16 100644 --- a/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart +++ b/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart @@ -225,7 +225,7 @@ class _CloudStreamSourcesPageState extends State { borderRadius: BorderRadius.circular(10), child: Image.network(_icon, width: 44, height: 44, fit: BoxFit.cover, errorBuilder: (_, _, _) => - const Icon(Icons.extension_outlined, color: AppColors.primary, size: 40)), + Icon(Icons.extension_outlined, color: AppColors.primary, size: 40)), ), const SizedBox(width: 12), const Expanded( @@ -349,7 +349,7 @@ class _CloudStreamSourcesPageState extends State { borderRadius: BorderRadius.circular(8), child: Image.network(_icon, width: 34, height: 34, fit: BoxFit.cover, errorBuilder: (_, _, _) => - const Icon(Icons.extension_outlined, color: AppColors.primary)), + Icon(Icons.extension_outlined, color: AppColors.primary)), ), title: Text(name, maxLines: 1, diff --git a/lib/features/comments/presentation/widgets/comments_panel.dart b/lib/features/comments/presentation/widgets/comments_panel.dart index 0048a88a..8dd50110 100644 --- a/lib/features/comments/presentation/widgets/comments_panel.dart +++ b/lib/features/comments/presentation/widgets/comments_panel.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; import 'package:soplay/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart'; import 'package:soplay/features/comments/presentation/widgets/comment_card.dart'; @@ -87,7 +88,7 @@ class _CommentsViewState extends State<_CommentsView> { bool loggedIn, ) { if (state.loading) { - return const Center( + return Center( child: CircularProgressIndicator( color: AppColors.primary, strokeWidth: 2.4, @@ -237,7 +238,7 @@ class _CommentsViewState extends State<_CommentsView> { const _SignInPrompt() else Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, border: Border( top: BorderSide(color: AppColors.divider, width: 0.6), @@ -289,7 +290,7 @@ class _SignInPrompt extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.login_rounded, size: 18), @@ -362,7 +363,7 @@ class _CommentTree extends StatelessWidget { child: Column( children: [ if (repliesLoading && replies.isEmpty) - const Padding( + Padding( padding: EdgeInsets.symmetric(vertical: 16), child: SizedBox( width: 18, diff --git a/lib/features/desktop_share/presentation/pages/desktop_share_page.dart b/lib/features/desktop_share/presentation/pages/desktop_share_page.dart index 0624c654..709542c0 100644 --- a/lib/features/desktop_share/presentation/pages/desktop_share_page.dart +++ b/lib/features/desktop_share/presentation/pages/desktop_share_page.dart @@ -345,7 +345,7 @@ class _DesktopSharePageState extends State { }), ), if (!_shareAll) ...[ - const Divider(color: AppColors.divider, height: 16), + Divider(color: AppColors.divider, height: 16), _pickerControls(), const SizedBox(height: 4), _pickerList(), @@ -608,7 +608,7 @@ class _DesktopSharePageState extends State { ), child: Text( '${i + 1}', - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 12, fontWeight: FontWeight.w800, diff --git a/lib/features/detail/presentation/pages/actor_page.dart b/lib/features/detail/presentation/pages/actor_page.dart index b4230965..5d619b65 100644 --- a/lib/features/detail/presentation/pages/actor_page.dart +++ b/lib/features/detail/presentation/pages/actor_page.dart @@ -56,8 +56,21 @@ class _ActorScaffold extends StatefulWidget { class _ActorScaffoldState extends State<_ActorScaffold> { late final ScrollController _scroll; final ValueNotifier _collapse = ValueNotifier(0); - Color _accent = const Color(0xFFB20710); - Color _accentDeep = const Color(0xFF3A0306); + // Shown until the poster's palette is extracted (and kept when there is no + // poster). Follows the chosen accent rather than the old hard-coded brand + // red, so the hero never opens in a colour the app no longer uses. + Color _accent = AppColors.primaryDark; + Color _accentDeep = _deepen(AppColors.primary); + + /// The very dark, desaturated floor a hero gradient falls to. The old literal + /// #3A0306 is what this produces for the default red. + static Color _deepen(Color c) { + final hsl = HSLColor.fromColor(c); + return hsl + .withSaturation((hsl.saturation * 0.94).clamp(0.0, 1.0)) + .withLightness((hsl.lightness * 0.265).clamp(0.0, 1.0)) + .toColor(); + } static const double _heroExtent = 320; @@ -154,7 +167,7 @@ class _ActorScaffoldState extends State<_ActorScaffold> { ), ), if (state is ViewAllLoading) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 40), child: Center( @@ -298,7 +311,7 @@ class _ActorScaffoldState extends State<_ActorScaffold> { ), ), if (state.isLoadingMore) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 8156c4e4..cf379e78 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -751,7 +751,7 @@ class _DetailViewState extends State<_DetailView> child: Column( mainAxisSize: MainAxisSize.min, children: [ - const CircularProgressIndicator(color: AppColors.primary), + CircularProgressIndicator(color: AppColors.primary), const SizedBox(height: 18), TextButton( onPressed: () => Navigator.of(dctx).pop(), @@ -1470,7 +1470,7 @@ class _ErrorView extends StatelessWidget { onPressed: onSolveCloudflare, style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.border), + side: BorderSide(color: AppColors.border), ), icon: const Icon(Icons.shield_outlined, size: 18), label: Text('cloudflare.solve'.tr()), diff --git a/lib/features/detail/presentation/pages/episodes_page.dart b/lib/features/detail/presentation/pages/episodes_page.dart index 06eb2a42..06a27dcf 100644 --- a/lib/features/detail/presentation/pages/episodes_page.dart +++ b/lib/features/detail/presentation/pages/episodes_page.dart @@ -369,7 +369,7 @@ class _EpisodesPageState extends State { }, ), if (_loadingMore) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 22), child: Center( @@ -738,7 +738,7 @@ class _EpisodeRow extends StatelessWidget { value: progress!, minHeight: 3, backgroundColor: AppColors.divider, - valueColor: const AlwaysStoppedAnimation( + valueColor: AlwaysStoppedAnimation( AppColors.primary, ), ), @@ -795,7 +795,7 @@ class _DownloadControl extends StatelessWidget { return Container( width: 34, height: 34, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceVariant, shape: BoxShape.circle, ), @@ -811,13 +811,15 @@ class _DownloadControl extends StatelessWidget { final button = Container( width: 34, height: 34, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceVariant, shape: BoxShape.circle, ), child: Icon( failed ? Icons.refresh_rounded : Icons.download_outlined, - color: failed ? AppColors.primary : AppColors.textSecondary, + // The retry glyph IS the failure signal on this row — it has to stay + // red rather than turn into whatever the accent is. + color: failed ? AppColors.error : AppColors.textSecondary, size: 18, ), ); diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index c395089b..bb85e8b4 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -75,6 +75,11 @@ extension _PlayerControls on _PlayerPageState { void _seekRelative(Duration delta) { if (_partyBlockLocal()) return; + // There is nothing to skip forward into on a broadcast, and skipping back + // lands outside the DVR window on most channels — the stream stalls and the + // viewer's only way out is to leave and come back. The double-tap gesture + // reaches here too, which is how a stray tap used to kill a channel. + if (_isLive) return; final c = _controller; if (c == null || !c.value.isInitialized) return; final next = c.value.position + delta; @@ -94,6 +99,9 @@ extension _PlayerControls on _PlayerPageState { if (_partyBlockLocal()) return; final c = _controller; if (c == null || !c.value.isInitialized) return; + // `Go live` is the one seek a broadcast accepts, and it asks for the very + // end; anything else is a scrub bar that should not have been reachable. + if (_isLive && position < c.value.duration) return; c.seekTo(position); _scheduleHide(); if (!_isLive) { diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index ed652f56..6a7cf3c2 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -73,6 +73,24 @@ part 'player_page.tv.dart'; /// Hard ceiling on auto-retries per episode — see [_PlayerPageState._lifetimeRetries]. const int _kMaxLifetimeRetries = 4; +/// Reconnect budget for a live broadcast, which is a different problem. +/// +/// A film either plays or is broken, so four attempts in a session is generous. +/// A channel drops — a segment gap, a CDN failing over, a phone changing +/// network — and the only correct response is to reconnect and keep watching. +/// Four in a two-hour evening meant the player gave up permanently on something +/// that was working again seconds later. +const int _kMaxLiveRetries = 1000; + +/// How long to wait before reconnecting a dropped channel, by attempt. +/// +/// Fast enough that a blip is invisible, and backing off so a channel that is +/// genuinely off air is not hammered all evening. +Duration _liveRetryBackoff(int attempt) { + const steps = [1, 2, 4, 8, 15]; + return Duration(seconds: steps[attempt < steps.length ? attempt : steps.length - 1]); +} + class PlayerPage extends StatefulWidget { const PlayerPage({super.key, required this.args}); final PlayerArgs args; @@ -113,6 +131,15 @@ class _PlayerPageState extends State Map _headers = const {}; bool _isNetworkVideo = false; bool _isHls = false; + /// True while the current media is a live broadcast. + /// + /// Seeded from what the caller SAID it is rather than guessed alone: Live TV + /// hands the player `type: 'live'`, and a stream that is live does not stop + /// being live because its playlist happens to report a duration. Plenty of + /// live HLS carries a sliding DVR window, so the duration heuristic below + /// says "not live" for real channels — which drew a scrub bar on something + /// unscrubbable, ran frame-preview extraction against an endless stream, and + /// treated the live edge as the end of the file. bool _isLive = false; List _videoSources = const []; diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 9f93519a..b88bcee4 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -411,6 +411,11 @@ extension _PlayerMedia on _PlayerPageState { _videoUrl = effectiveUrl; _mediaType = type; _isNetworkVideo = !isLocal; + // Known BEFORE the first frame, not after it. A channel that is down at the + // moment you open it fails during initialize(), and the error path has to + // already know it is looking at a broadcast — otherwise the one case that + // most needs reconnecting is the one that gets a dead end. + if (type == 'live' || widget.args.type == 'live') _isLive = true; try { await controller.initialize(); @@ -431,7 +436,13 @@ extension _PlayerMedia on _PlayerPageState { return; } final dur = controller.value.duration; - _isLive = dur <= Duration.zero || dur.inHours >= 12; + // What the caller said, OR what the duration implies. The declared type is + // the reliable half: a live channel with a DVR window reports a perfectly + // finite duration and would otherwise be treated as a file. + _isLive = _mediaType == 'live' || + widget.args.type == 'live' || + dur <= Duration.zero || + dur.inHours >= 12; PlayerLog.instance.setContext({ 'live': _isLive.toString(), 'duration': _isLive ? 'live' : dur.toString(), @@ -517,6 +528,15 @@ extension _PlayerMedia on _PlayerPageState { _isCodecError = true; msg = 'This video format is not supported on your device. You can try playing it in your browser.'; + } else if (_isLive && _lifetimeRetries < _kMaxLiveRetries) { + // A channel that would not open is very often a channel that will open + // in a moment — the origin was mid-restart, or the playlist rolled. The + // same reconnect the mid-playback path uses applies here. + _retryAttempts++; + _lifetimeRetries++; + _autoRetrying = true; + _liveReconnect(); + return; } else if (_isRecoverableError(raw) && _retryAttempts < 2 && _lifetimeRetries < _kMaxLifetimeRetries) { @@ -601,14 +621,25 @@ extension _PlayerMedia on _PlayerPageState { if (msg != null && msg != _lastError && mounted) { _lastError = msg; _plog('playback error: $msg', level: LogLevel.error); + // A live channel reconnects rather than giving up: a drop mid-broadcast + // is the normal case, not a broken source. It also reconnects on errors + // a file would call fatal — a 403 or a 404 on a live edge is usually a + // rotated token or a segment that expired while we were away, and the + // next playlist fetch has the current one. + final liveRetry = _isLive && _lifetimeRetries < _kMaxLiveRetries; if (!_autoRetrying && - _retryAttempts < 2 && - _lifetimeRetries < _kMaxLifetimeRetries && - _isRecoverableError(msg)) { + (liveRetry || + (_retryAttempts < 2 && + _lifetimeRetries < _kMaxLifetimeRetries && + _isRecoverableError(msg)))) { _retryAttempts++; _lifetimeRetries++; _autoRetrying = true; - _autoRetry(); + if (_isLive) { + _liveReconnect(); + } else { + _autoRetry(); + } return; } setState(() => _errorMessage = _humanizeError(msg)); @@ -679,6 +710,39 @@ extension _PlayerMedia on _PlayerPageState { if (changed && mounted) setState(() {}); } + /// Reconnects a dropped live channel, backing off between attempts. + /// + /// Deliberately NOT [_autoRetry]: that one's first move is to fall through to + /// the next quality source, which for a channel with a single url is a no-op, + /// and its second is to surface an error. A broadcast has nowhere else to go — + /// the same url IS the channel — so this reopens it, waits longer each time, + /// and keeps the last frame on screen instead of flashing an error at somebody + /// whose stream will be back in two seconds. + Future _liveReconnect() async { + if (!mounted) return; + final attempt = _lifetimeRetries; + _plog('live stream dropped — reconnecting (attempt $attempt)'); + + setState(() { + _stage = _LoadingStage.loading; + _errorMessage = null; + _isCodecError = false; + }); + + await Future.delayed(_liveRetryBackoff(attempt - 1)); + if (!mounted) return; + + final url = _videoUrl; + if (url == null) { + _autoRetrying = false; + return; + } + await _disposeController(); + if (!mounted) return; + await _initializeWith(url: url, headers: _headers, type: _mediaType); + _autoRetrying = false; + } + Future _autoRetry() async { if (!mounted) return; diff --git a/lib/features/detail/presentation/pages/player_page.subtitles.dart b/lib/features/detail/presentation/pages/player_page.subtitles.dart index 9f842141..5774bd30 100644 --- a/lib/features/detail/presentation/pages/player_page.subtitles.dart +++ b/lib/features/detail/presentation/pages/player_page.subtitles.dart @@ -168,7 +168,7 @@ extension _PlayerSubtitles on _PlayerPageState { // the standout action, not another neutral row. ListTile( focusColor: _kTvFocusFill, - leading: const Icon(Icons.auto_awesome_rounded, + leading: Icon(Icons.auto_awesome_rounded, color: AppColors.primaryLight, size: 20), title: Text('player.ai_translate_menu'.tr(), style: const TextStyle( @@ -220,17 +220,66 @@ extension _PlayerSubtitles on _PlayerPageState { ); } - void _toast(String message) { + void _toast(String message, {IconData icon = Icons.info_outline_rounded}) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( SnackBar( - content: Text(message), + content: Row( + children: [ + Icon(icon, size: 18, color: AppColors.primaryLight), + const SizedBox(width: 10), + Expanded( + child: Text(message, + style: const TextStyle(fontSize: 13, color: Colors.white)), + ), + ], + ), + backgroundColor: const Color(0xFF1C1C1E), behavior: SnackBarBehavior.floating, + elevation: 8, + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.white.withValues(alpha: 0.08)), + ), duration: const Duration(seconds: 2), ), ); } + /// TMDB id + type parsed from the content url, for finding and publishing + /// translations by the media they belong to. + ({String id, String type})? _tmdbRef() { + final url = widget.args.contentUrl; + if (url == null || url.isEmpty) return null; + final m = RegExp(r'themoviedb\.org/(movie|tv)/(\d+)').firstMatch(url); + if (m == null) return null; + return (id: m.group(2)!, type: m.group(1)!); + } + + /// Serialises translated cues to SubRip, for publishing a finished track. + String _buildSrt(List cues) { + String stamp(Duration d) { + final h = d.inHours.toString().padLeft(2, '0'); + final m = (d.inMinutes % 60).toString().padLeft(2, '0'); + final sec = (d.inSeconds % 60).toString().padLeft(2, '0'); + final ms = (d.inMilliseconds % 1000).toString().padLeft(3, '0'); + return '$h:$m:$sec,$ms'; + } + + final buf = StringBuffer(); + for (var i = 0; i < cues.length; i++) { + final c = cues[i]; + buf.writeln(i + 1); + buf.writeln('${stamp(c.start)} --> ${stamp(c.end)}'); + buf.writeln(c.text); + buf.writeln(); + } + return buf.toString(); + } + int? _currentEpisodeNumber() { if (!widget.args.isSerial) return null; // Read the episode number from the entity — parsing the ' · <label>' @@ -290,14 +339,27 @@ extension _PlayerSubtitles on _PlayerPageState { List<OnlineSubtitle> results = const []; SubtitleQuota? quota; var quotaLoaded = false; + List<ReadySubtitle> ready = const []; return StatefulBuilder( builder: (ctx, setSheet) { if (!quotaLoaded) { quotaLoaded = true; - const SubtitleTranslationService().fetchQuota().then((q) { + const service = SubtitleTranslationService(); + service.fetchQuota().then((q) { if (ctx.mounted) setSheet(() => quota = q); }); + final ref = _tmdbRef(); + if (ref != null) { + service.fetchReady( + tmdbId: ref.id, + type: ref.type, + season: _currentSeasonNumber(), + episode: _currentEpisodeNumber(), + ).then((r) { + if (ctx.mounted && r.isNotEmpty) setSheet(() => ready = r); + }); + } } Future<void> runSearch() async { final q = queryCtrl.text.trim(); @@ -417,6 +479,7 @@ extension _PlayerSubtitles on _PlayerPageState { ), const SizedBox(height: 10), _aiTranslateBanner(quota), + if (ready.isNotEmpty) _readyTranslations(sheetCtx, ready), const SizedBox(height: 8), ConstrainedBox( constraints: BoxConstraints(maxHeight: listMaxHeight), @@ -512,6 +575,85 @@ extension _PlayerSubtitles on _PlayerPageState { ); } + /// Translations other viewers already made for this episode. + /// + /// One tap loads a ready file straight from the cache — no waiting, no quota + /// spent — which is the fast path once a popular title has been translated + /// once. + Widget _readyTranslations(BuildContext sheetCtx, List<ReadySubtitle> ready) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 10, 16, 0), + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + Icon(Icons.bolt_rounded, + size: 15, color: AppColors.primaryLight), + const SizedBox(width: 6), + Text('player.ready_translations'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700)), + ], + ), + ), + for (final r in ready) + ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: const Icon(Icons.subtitles_rounded, + size: 18, color: Colors.white70), + title: Text( + '${_langName(r.targetLang)} · ${r.cueCount} ${'player.lines'.tr()}', + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + trailing: const Icon(Icons.download_rounded, + size: 16, color: Colors.white38), + onTap: () { + Navigator.of(sheetCtx).pop(); + _applyReadyTranslation(r); + }, + ), + ], + ), + ); + } + + String _langName(String code) { + const map = { + 'uz': "O'zbekcha", 'ru': 'Русский', 'en': 'English', 'tr': 'Türkçe', + 'ar': 'العربية', 'de': 'Deutsch', 'fr': 'Français', 'es': 'Español', + }; + return map[code.toLowerCase()] ?? code.toUpperCase(); + } + + /// Loads a ready translation straight from its url. + Future<void> _applyReadyTranslation(ReadySubtitle r) async { + final entity = SubtitleEntity( + label: 'AI · ${r.targetLang.toUpperCase()}', + file: r.url, + ); + setState(() => _subtitles = [..._subtitles, entity]); + final added = _subtitles.length - 1; + final ok = await _loadSubtitle(added); + if (!mounted) return; + if (ok) { + _toast('player.subtitle_loaded'.tr(), icon: Icons.check_circle_rounded); + } else if (added < _subtitles.length && _activeSubtitleIndex != added) { + setState(() => _subtitles = [..._subtitles]..removeAt(added)); + } + } + /// The prominent AI-translate explainer at the top of the search sheet. /// /// Names the target language and shows how many translations are left today, @@ -724,7 +866,19 @@ extension _PlayerSubtitles on _PlayerPageState { setState(() => _captionFile = List<Caption>.from(translated)); } } - if (mounted) _toast('player.subtitle_loaded'.tr()); + if (mounted) _toast('player.subtitle_loaded'.tr(), icon: Icons.check_circle_rounded); + // Publish it so the next viewer of this episode loads it in one tap. + final ref = _tmdbRef(); + if (ref != null && applied == source.length) { + unawaited(service.publishReady( + tmdbId: ref.id, + type: ref.type, + season: _currentSeasonNumber(), + episode: _currentEpisodeNumber(), + targetLang: targetLang, + srt: _buildSrt(translated), + )); + } } on SubtitleDailyLimitReached catch (e) { if (mounted) { _toast(e.message); @@ -1478,12 +1632,12 @@ class _AiTranslateChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.auto_awesome_rounded, + Icon(Icons.auto_awesome_rounded, size: 13, color: AppColors.primaryLight), const SizedBox(width: 5), Text( 'AI → $lang', - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontSize: 11.5, fontWeight: FontWeight.w700, diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index 9b201320..3cf98e94 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -204,7 +204,7 @@ class _LoadingOverlay extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 360), child: ClipRRect( borderRadius: BorderRadius.circular(3), - child: const LinearProgressIndicator( + child: LinearProgressIndicator( minHeight: 3, backgroundColor: Colors.white12, valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary), @@ -546,7 +546,7 @@ class _EpisodeRow extends StatelessWidget { ), ), if (isActive) - const Icon( + Icon( Icons.play_arrow_rounded, color: AppColors.primary, size: 22, @@ -606,7 +606,7 @@ class _QualityRow extends StatelessWidget { ), child: Text( 'player.default'.tr(), - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontSize: 10, fontWeight: FontWeight.w700, diff --git a/lib/features/detail/presentation/widgets/detail_hero.dart b/lib/features/detail/presentation/widgets/detail_hero.dart index 7242c7ed..db457a73 100644 --- a/lib/features/detail/presentation/widgets/detail_hero.dart +++ b/lib/features/detail/presentation/widgets/detail_hero.dart @@ -40,10 +40,14 @@ class DetailHeroBackground extends StatelessWidget { gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, + // All three stops are the page background at falling + // opacity — that is what makes the poster dissolve INTO the + // page. The middle one used to be the literal #181818, which + // left a grey band hanging in mid-air under AMOLED. colors: [ AppColors.background, - const Color(0xEE181818), - const Color(0x00000000), + AppColors.background.withValues(alpha: 0.933), + AppColors.background.withValues(alpha: 0.0), ], stops: const [0.0, 0.5, 1.0], ), @@ -108,13 +112,13 @@ class _ThumbnailImage extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), CachedNetworkImage( imageUrl: url!, fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 240), fadeInCurve: Curves.easeOut, - placeholder: (_, _) => const ColoredBox(color: AppColors.surfaceVariant), + placeholder: (_, _) => ColoredBox(color: AppColors.surfaceVariant), errorWidget: (_, _, _) => Container( color: AppColors.surfaceVariant, child: const Center( diff --git a/lib/features/detail/presentation/widgets/detail_info.dart b/lib/features/detail/presentation/widgets/detail_info.dart index 84a6dde1..5f54d740 100644 --- a/lib/features/detail/presentation/widgets/detail_info.dart +++ b/lib/features/detail/presentation/widgets/detail_info.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/core/theme/app_theme.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/detail/domain/entities/detail_entity.dart'; import 'package:soplay/features/history/data/history_service.dart'; @@ -144,7 +145,7 @@ class _ContinueWatchingCard extends StatelessWidget { foregroundColor: Colors.black, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.play_arrow_rounded, size: 26), @@ -169,7 +170,7 @@ class _ContinueWatchingCard extends StatelessWidget { value: progress, minHeight: 3, backgroundColor: AppColors.surfaceVariant, - valueColor: const AlwaysStoppedAnimation(AppColors.primary), + valueColor: AlwaysStoppedAnimation(AppColors.primary), ), ), ], @@ -475,7 +476,7 @@ class _PlayButton extends StatelessWidget { backgroundColor: Colors.white, foregroundColor: Colors.black, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kButtonRadius)), ), icon: Icon( reader ? Icons.menu_book_rounded : Icons.play_arrow_rounded, diff --git a/lib/features/detail/presentation/widgets/detail_more_menu.dart b/lib/features/detail/presentation/widgets/detail_more_menu.dart index 8c868f38..744c2582 100644 --- a/lib/features/detail/presentation/widgets/detail_more_menu.dart +++ b/lib/features/detail/presentation/widgets/detail_more_menu.dart @@ -407,12 +407,12 @@ class _DetailMoreMenuState extends State<_DetailMoreMenu> { width: 38, height: 54, fit: BoxFit.cover, - placeholder: (_, _) => const SizedBox( + placeholder: (_, _) => SizedBox( width: 38, height: 54, child: ColoredBox(color: AppColors.surfaceVariant), ), - errorWidget: (_, _, _) => const SizedBox( + errorWidget: (_, _, _) => SizedBox( width: 38, height: 54, child: ColoredBox(color: AppColors.surfaceVariant), @@ -522,7 +522,7 @@ class _DetailMoreMenuState extends State<_DetailMoreMenu> { : widget.onMoveToPrivate, ), ), - const Divider( + Divider( color: AppColors.divider, height: 13, indent: 16, @@ -574,7 +574,7 @@ class _DetailMoreMenuState extends State<_DetailMoreMenu> { label: 'detail.copy_link'.tr(), onTap: _copyLink, ), - const Divider( + Divider( color: AppColors.divider, height: 13, indent: 16, diff --git a/lib/features/detail/presentation/widgets/detail_related.dart b/lib/features/detail/presentation/widgets/detail_related.dart index 411a1ca6..a3d3a419 100644 --- a/lib/features/detail/presentation/widgets/detail_related.dart +++ b/lib/features/detail/presentation/widgets/detail_related.dart @@ -141,7 +141,7 @@ class _RelatedThumbnail extends StatelessWidget { imageUrl: url!, fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 180), - placeholder: (_, _) => const ColoredBox(color: AppColors.surfaceVariant), + placeholder: (_, _) => ColoredBox(color: AppColors.surfaceVariant), errorWidget: (_, _, _) => Container( color: AppColors.surfaceVariant, child: const Center( diff --git a/lib/features/detail/presentation/widgets/detail_screenshots.dart b/lib/features/detail/presentation/widgets/detail_screenshots.dart index 4949f17a..1bad3dc6 100644 --- a/lib/features/detail/presentation/widgets/detail_screenshots.dart +++ b/lib/features/detail/presentation/widgets/detail_screenshots.dart @@ -105,7 +105,7 @@ class _ScreenshotCard extends StatelessWidget { fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 180), placeholder: (_, _) => - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), errorWidget: (_, _, _) => const Center( child: Icon( Icons.broken_image_outlined, diff --git a/lib/features/detail/presentation/widgets/player_engine_sheet.dart b/lib/features/detail/presentation/widgets/player_engine_sheet.dart index 75f12088..161e2ab6 100644 --- a/lib/features/detail/presentation/widgets/player_engine_sheet.dart +++ b/lib/features/detail/presentation/widgets/player_engine_sheet.dart @@ -6,6 +6,7 @@ import 'package:soplay/core/player/media_controller.dart' import 'package:soplay/core/player/player_engine.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; /// Icon shown for each backend. Shared with Settings → Player so the row a /// user taps in the sheet is visually the same row they see in settings. @@ -170,7 +171,7 @@ Future<bool> showPlayerEngineSheet(BuildContext context) async { backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: () => Navigator.of(builderContext).pop(true), diff --git a/lib/features/download/presentation/pages/downloads_page.dart b/lib/features/download/presentation/pages/downloads_page.dart index 91fd49dd..717676c9 100644 --- a/lib/features/download/presentation/pages/downloads_page.dart +++ b/lib/features/download/presentation/pages/downloads_page.dart @@ -70,8 +70,10 @@ class _DownloadsPageState extends State<DownloadsPage> { }, child: Text( 'general.delete'.tr(), + // error, not primary: this wipes every download at once, and the + // single-row delete two screens down already reads as error. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontWeight: FontWeight.w700, ), ), @@ -203,7 +205,7 @@ class _DownloadsPageState extends State<DownloadsPage> { else SliverList.separated( itemCount: _items.length, - separatorBuilder: (_, _) => const Divider( + separatorBuilder: (_, _) => Divider( color: AppColors.divider, height: 1, indent: 82, @@ -277,8 +279,9 @@ class _PillButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: Text( label, + // The page's only pill is "Clear all" — destructive, so error. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontSize: 12, fontWeight: FontWeight.w600, ), @@ -440,7 +443,7 @@ class _DownloadRow extends StatelessWidget { value: item.progress, minHeight: 3, backgroundColor: AppColors.divider, - valueColor: const AlwaysStoppedAnimation<Color>( + valueColor: AlwaysStoppedAnimation<Color>( AppColors.primary, ), ), @@ -483,8 +486,11 @@ class _DownloadRow extends StatelessWidget { else if (item.status == DownloadStatus.failed) Text( 'downloads.failed'.tr(), + // Sits directly under the green "completed" label — + // a failure has to be the opposite colour, not the + // theme colour. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontSize: 11, fontWeight: FontWeight.w600, ), @@ -511,7 +517,7 @@ class _DownloadRow extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.15), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.refresh_rounded, color: AppColors.primary, size: 20, @@ -522,7 +528,7 @@ class _DownloadRow extends StatelessWidget { Container( width: 36, height: 36, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, shape: BoxShape.circle, ), diff --git a/lib/features/extensions/presentation/repo_file_import.dart b/lib/features/extensions/presentation/repo_file_import.dart index 1e26ee16..ba327bec 100644 --- a/lib/features/extensions/presentation/repo_file_import.dart +++ b/lib/features/extensions/presentation/repo_file_import.dart @@ -211,7 +211,7 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { return SafeArea( top: false, child: Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -240,7 +240,7 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { color: AppColors.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(11), ), - child: const Icon(Icons.extension_rounded, + child: Icon(Icons.extension_rounded, color: AppColors.primary, size: 22), ), const SizedBox(width: 12), diff --git a/lib/features/history/data/history_service.dart b/lib/features/history/data/history_service.dart index 5ac710f9..aef979a4 100644 --- a/lib/features/history/data/history_service.dart +++ b/lib/features/history/data/history_service.dart @@ -127,6 +127,17 @@ class HistoryService { if (keys.isNotEmpty) revision.value++; } + /// Drops every local row WITHOUT recording tombstones. + /// + /// The distinction from [clearAll] matters: this is not a user deleting their + /// history, it is these rows turning out to belong to a different account + /// (see HistorySyncService.adoptFor). Tombstoning them would push a delete + /// for each one into whichever account signs in next. + Future<void> clearLocalOnly() async { + await _box.clear(); + revision.value++; + } + Future<void> clearAll() async { if (getIt.isRegistered<HistorySyncService>()) { final sync = getIt<HistorySyncService>(); diff --git a/lib/features/history/data/history_sync_service.dart b/lib/features/history/data/history_sync_service.dart index 05b37a8e..c98aba38 100644 --- a/lib/features/history/data/history_sync_service.dart +++ b/lib/features/history/data/history_sync_service.dart @@ -40,6 +40,7 @@ class HistorySyncService { static const String _cursorKey = 'history_sync_cursor'; static const String _pushedAtKey = 'history_sync_pushed_at'; static const String _tombstonesKey = 'history_sync_tombstones'; + static const String _ownerKey = 'history_sync_owner'; bool _running = false; @@ -133,12 +134,46 @@ class HistorySyncService { } /// Sign-out must not leave one account's cursor pointing at another's history. + /// + /// The Hive rows are deliberately LEFT ALONE here — someone who signs out and + /// keeps watching still has their Continue Watching list. What makes that safe + /// is [adoptFor], which refuses to hand those rows to a different account. Future<void> clear() async { await _state.delete(_cursorKey); await _state.delete(_pushedAtKey); await _state.delete(_tombstonesKey); } + /// Decides whether the rows already on this phone belong to [userId]. + /// + /// Sign-out clears the cursor, which resets the push watermark to zero — so + /// the first sync after ANY sign-in uploads every local row. That is right + /// when the same person signs back in, or when someone who had been watching + /// signed out finally makes an account: their viewing follows them. + /// + /// It is badly wrong when the next sign-in is somebody else. Handing one + /// person's watch history to another account is not a sync bug, it is a leak, + /// and nothing was stopping it. So the rows are stamped with the account they + /// belong to, and a different owner wipes them before the first sync can push + /// them anywhere. + /// + /// Unstamped rows have no owner yet (signed-out viewing, or an install from + /// before this existed) and are adopted rather than destroyed. + Future<void> adoptFor(String userId) async { + final id = userId.trim(); + if (id.isEmpty) return; + + final owner = _state.get(_ownerKey) as String?; + if (owner == id) return; + + if (owner != null && owner != id) { + await _local.clearLocalOnly(); + // The cursor and the outgoing tombstones described the previous account. + await clear(); + } + await _state.put(_ownerKey, id); + } + // ─── applying the server's answer ────────────────────────────────────────── Future<void> _applyRemote(List<HistorySyncItem> items) async { diff --git a/lib/features/history/presentation/pages/history_page.dart b/lib/features/history/presentation/pages/history_page.dart index a0ac323d..951ecee3 100644 --- a/lib/features/history/presentation/pages/history_page.dart +++ b/lib/features/history/presentation/pages/history_page.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/profile/presentation/widgets/library_accents.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; import 'package:soplay/features/history/data/history_service.dart'; import 'package:soplay/features/history/data/history_sync_service.dart'; @@ -80,11 +81,17 @@ class _HistoryPageState extends State<HistoryPage> { .rememberClearedAll() .then((_) => _historyService.clearAll()) .then((_) => _syncService.sync()); + // Appearance suggests accents from these posters and caches the + // result for the app run. With the library gone, the cache is + // suggesting colours from titles the user just deleted. + LibraryAccents.invalidate(); }, child: Text( 'history.clear'.tr(), + // error, not primary: clearing history writes a tombstone, so + // sync cannot bring it back. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontWeight: FontWeight.w700, ), ), @@ -235,8 +242,9 @@ class _PillButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: Text( label, + // The page's only pill is "Clear all" — destructive, so error. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontSize: 12, fontWeight: FontWeight.w600, ), @@ -349,7 +357,7 @@ class _HistoryRow extends StatelessWidget { value: item.progress, minHeight: 3, backgroundColor: Colors.black45, - valueColor: const AlwaysStoppedAnimation<Color>( + valueColor: AlwaysStoppedAnimation<Color>( AppColors.primary, ), ), @@ -381,7 +389,7 @@ class _HistoryRow extends StatelessWidget { if (item.isSerial && item.episodeNumber != null) ...[ Text( 'EP ${item.episodeNumber}', - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 11, fontWeight: FontWeight.w700, @@ -430,7 +438,7 @@ class _HistoryRow extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.15), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.play_arrow_rounded, color: AppColors.primary, size: 20, diff --git a/lib/features/home/presentation/widgets/genre_card.dart b/lib/features/home/presentation/widgets/genre_card.dart index a28eee6c..e59843fe 100644 --- a/lib/features/home/presentation/widgets/genre_card.dart +++ b/lib/features/home/presentation/widgets/genre_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/system/responsive.dart'; +import '../../../../core/theme/app_colors.dart'; import '../../../search/domain/entities/genre_entity.dart'; import '../../domain/entities/view_all.dart'; import 'home_shared_widgets.dart'; @@ -43,6 +44,9 @@ class GenreCard extends StatelessWidget { borderRadius: BorderRadius.zero, placeholderIcon: Icons.category_outlined, ), + // The scrim leans onto the accent as it deepens, so a wall of + // genre thumbnails carries the chosen colour instead of being + // twelve identical black fades. DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -50,11 +54,19 @@ class GenreCard extends StatelessWidget { end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.18), - Colors.black.withValues(alpha: 0.72), + AppColors.primaryDark.withValues(alpha: 0.30), + Colors.black.withValues(alpha: 0.78), ], + stops: const [0, 0.55, 1], ), ), ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container(height: 2.5, color: AppColors.primary), + ), Positioned( left: desktop ? 12 : 8, right: desktop ? 12 : 8, diff --git a/lib/features/home/presentation/widgets/home_banner.dart b/lib/features/home/presentation/widgets/home_banner.dart index 5921b6ac..fdf8c609 100644 --- a/lib/features/home/presentation/widgets/home_banner.dart +++ b/lib/features/home/presentation/widgets/home_banner.dart @@ -320,8 +320,8 @@ class _SlideOverlays extends StatelessWidget { end: Alignment.topCenter, colors: [ AppColors.background, - const Color(0xBB181818), - const Color(0x00000000), + AppColors.background.withValues(alpha: 0.733), + AppColors.background.withValues(alpha: 0.0), ], stops: const [0.0, 0.52, 1.0], ), @@ -712,19 +712,21 @@ class _DesktopSlideState extends State<_DesktopSlide> ), ), ), - // Scrims for text legibility. - const Positioned.fill( + // Scrims for text legibility. Same rule as _EdgeBlend directly + // below: every stop is the page background at some opacity, so the + // scrim follows the theme instead of freezing at #181818. + Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ - Color(0xF2181818), - Color(0xCC181818), - Color(0x00181818), + AppColors.background.withValues(alpha: 0.949), + AppColors.background.withValues(alpha: 0.8), + AppColors.background.withValues(alpha: 0.0), ], - stops: [0.0, 0.42, 0.72], + stops: const [0.0, 0.42, 0.72], ), ), ), @@ -880,12 +882,16 @@ class _DesktopSlideState extends State<_DesktopSlide> class _EdgeBlend extends StatelessWidget { const _EdgeBlend(); - static const Color _bg = AppColors.background; - static const Color _clear = Color(0x00181818); - + /// Every stop is the page background at some opacity — that is the whole + /// trick, the card fades into whatever the feed sits on. So they are derived + /// from [AppColors.background] rather than written as literals: under AMOLED + /// the feed is true black, and a hard-coded #181818 edge would leave a grey + /// halo glowing around the hero. @override Widget build(BuildContext context) { - return const IgnorePointer( + final bg = AppColors.background; + final clear = bg.withValues(alpha: 0.0); + return IgnorePointer( child: Stack( fit: StackFit.expand, children: [ @@ -895,8 +901,8 @@ class _EdgeBlend extends StatelessWidget { gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, - colors: [_bg, Color(0xCC181818), _clear], - stops: [0.0, 0.14, 0.5], + colors: [bg, bg.withValues(alpha: 0.8), clear], + stops: const [0.0, 0.14, 0.5], ), ), ), @@ -910,7 +916,7 @@ class _EdgeBlend extends StatelessWidget { gradient: LinearGradient( begin: Alignment.centerRight, end: Alignment.centerLeft, - colors: [Color(0xCC181818), _clear], + colors: [bg.withValues(alpha: 0.8), clear], ), ), ), @@ -925,7 +931,7 @@ class _EdgeBlend extends StatelessWidget { gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, - colors: [Color(0xB3181818), _clear], + colors: [bg.withValues(alpha: 0.702), clear], ), ), ), @@ -940,7 +946,7 @@ class _EdgeBlend extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x99181818), _clear], + colors: [bg.withValues(alpha: 0.6), clear], ), ), ), @@ -974,9 +980,12 @@ class _BannerButtonState extends State<_BannerButton> { @override Widget build(BuildContext context) { final primary = widget.primary; + // Play stays white — it sits on artwork and needs the highest contrast + // there is. The second action is where the accent belongs: tinted glass, + // so the row says which theme is on without ever fighting the poster. final bg = primary ? Colors.white - : Colors.white.withValues(alpha: _hover ? 0.28 : 0.18); + : AppColors.primary.withValues(alpha: _hover ? 0.46 : 0.30); final fg = primary ? Colors.black : Colors.white; return MouseRegion( diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 0dbad68f..3a31b2ad 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -7,6 +7,7 @@ import 'package:soplay/core/navigation/nav_controller.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/banners/domain/entities/banner_item.dart'; import 'package:soplay/features/banners/presentation/bloc/banners_bloc.dart'; @@ -20,6 +21,7 @@ import 'package:soplay/features/home/presentation/bloc/home/home_bloc.dart'; import 'package:soplay/features/home/presentation/bloc/home/home_event.dart'; import 'package:soplay/features/home/presentation/widgets/home_banner.dart'; import 'package:soplay/features/home/presentation/widgets/home_history_section.dart'; +import 'package:soplay/features/home/presentation/widgets/home_live_tv_section.dart'; import 'package:soplay/features/home/presentation/widgets/home_movie_section.dart'; import 'package:soplay/features/home/presentation/widgets/home_state_views.dart'; import 'package:soplay/features/search/domain/entities/genre_entity.dart'; @@ -235,6 +237,12 @@ class _HomeContentBody extends StatelessWidget { child: _GenreSection(genres: state.genres), ), ), + // Live TV, above the catalogue rails rather than buried in + // Profile. It loads itself and renders nothing until it has + // channels, so a backend with no line-up leaves Home unchanged. + const SliverToBoxAdapter( + child: RepaintBoundary(child: LiveTvSection()), + ), if (state.collectionLoading) const SliverToBoxAdapter(child: CollectionLoadingRow()), ...sectionSlivers, @@ -472,7 +480,7 @@ class _TelegramPromoSheetState extends State<_TelegramPromoSheet> { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Row( diff --git a/lib/features/home/presentation/widgets/home_history_section.dart b/lib/features/home/presentation/widgets/home_history_section.dart index 03717a46..805e7232 100644 --- a/lib/features/home/presentation/widgets/home_history_section.dart +++ b/lib/features/home/presentation/widgets/home_history_section.dart @@ -156,7 +156,7 @@ class _HistoryCard extends StatelessWidget { ), const SizedBox(height: 8), ListTile( - leading: const Icon( + leading: Icon( Icons.play_arrow_rounded, color: AppColors.primary, ), @@ -279,7 +279,7 @@ class _HistoryCard extends StatelessWidget { child: Container( width: 28, height: 28, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -299,7 +299,7 @@ class _HistoryCard extends StatelessWidget { value: item.progress, minHeight: 3, backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation<Color>( + valueColor: AlwaysStoppedAnimation<Color>( AppColors.primary, ), ), diff --git a/lib/features/home/presentation/widgets/home_live_tv_section.dart b/lib/features/home/presentation/widgets/home_live_tv_section.dart new file mode 100644 index 00000000..ab806a67 --- /dev/null +++ b/lib/features/home/presentation/widgets/home_live_tv_section.dart @@ -0,0 +1,275 @@ +import 'dart:convert'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/live_tv/data/live_tv_service.dart'; + +/// Live TV, where somebody might actually find it. +/// +/// The line-up was reachable only through Profile → Live TV, seven rows down a +/// settings list, which is a fine place for a preference and the wrong place for +/// a thousand channels. Live TV is content; it belongs where the content is. +/// +/// A rail rather than a link: a channel is chosen in a second and watched +/// immediately, so the row IS the feature — tap a logo and it plays. The header +/// still opens the full page for browsing folders and searching. +/// +/// Self-collapsing. It fetches on its own and renders nothing at all until it +/// has channels, so a backend without a line-up (or without a network) leaves +/// Home exactly as it was rather than showing an empty shelf or an error. +class LiveTvSection extends StatefulWidget { + const LiveTvSection({super.key}); + + /// How many logos to pull. Enough to fill the rail on a tablet and to feel + /// like a line-up rather than a shortcut, without paging a phone's Home. + static const int _limit = 20; + + @override + State<LiveTvSection> createState() => _LiveTvSectionState(); +} + +class _LiveTvSectionState extends State<LiveTvSection> { + List<LiveChannel> _channels = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future<void> _load() async { + try { + final page = await getIt<LiveTvService>().browse( + limit: LiveTvSection._limit, + ); + if (!mounted) return; + setState(() => _channels = page.channels); + } catch (_) { + // Offline, or a backend with no line-up. Home simply does not grow a + // Live TV row, which is the right failure for something nobody asked for + // on this screen. + } + } + + void _play(LiveChannel channel) { + // Live TV's own "Recently watched" rail is fed from here as well as from + // its page: a channel played from Home is still a channel you watched, and + // the card is what lets the rail draw it without its listing. + final hive = getIt<HiveService>(); + hive.pushLiveTvRecent(channel.id); + final cards = hive.getLiveTvCards(); + cards[channel.id] = { + 'name': channel.name, + 'streamUrl': channel.streamUrl, + 'logoUrl': channel.logoUrl ?? '', + 'category': channel.category, + if (channel.headers.isNotEmpty) 'headers': jsonEncode(channel.headers), + }; + hive.setLiveTvCards(cards); + context.push( + '/player', + extra: PlayerArgs( + title: channel.name, + provider: 'live', + headers: channel.headers, + movieUrl: channel.streamUrl, + thumbnail: channel.logoUrl, + // Live has nothing to resume to and no end to download. + type: 'live', + showDownloadAction: false, + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_channels.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => context.push('/live-tv'), + child: Padding( + padding: const EdgeInsets.fromLTRB(17, 18, 20, 14), + child: Row( + children: [ + const Icon( + Icons.live_tv_rounded, + color: AppColors.textSecondary, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'live_tv.title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + const SizedBox(width: 8), + const _LiveBadge(), + const Spacer(), + Text( + 'home.view_all'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: 18, + ), + ], + ), + ), + ), + SizedBox( + height: 104, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 14), + itemCount: _channels.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (_, i) => + _ChannelTile(channel: _channels[i], onTap: _play), + ), + ), + ], + ), + ); + } +} + +/// One channel: its logo, and its name underneath. +/// +/// Logo-forward and square, because a channel is recognised by its mark long +/// before its name is read — which is the whole difference between scanning a +/// line-up and reading a list. +class _ChannelTile extends StatelessWidget { + const _ChannelTile({required this.channel, required this.onTap}); + + final LiveChannel channel; + final ValueChanged<LiveChannel> onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 76, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onTap(channel), + child: Container( + width: 76, + height: 76, + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withValues(alpha: 0.06), + ), + ), + child: channel.logoUrl == null + ? const Icon( + Icons.live_tv_rounded, + size: 26, + color: AppColors.textHint, + ) + : CachedNetworkImage( + imageUrl: channel.logoUrl!, + fit: BoxFit.contain, + // A broadcaster's mark is drawn for a light background + // as often as not, so it is never tinted or cropped — + // just fitted, and given a neutral tile to sit on. + errorWidget: (_, _, _) => const Icon( + Icons.live_tv_rounded, + size: 26, + color: AppColors.textHint, + ), + placeholder: (_, _) => const SizedBox.shrink(), + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + channel.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } +} + +/// The small red-dot LIVE marker. +/// +/// Static rather than pulsing: this sits in a scrolling feed, and an animation +/// running forever on Home costs a frame callback for the whole session to say +/// something a colour already says. +class _LiveBadge extends StatelessWidget { + const _LiveBadge(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 5, + height: 5, + // Not const: the accent is a user setting, so AppColors.primary is + // a getter and cannot appear in a constant expression. + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + 'LIVE', + style: TextStyle( + color: AppColors.primary, + fontSize: 9, + fontWeight: FontWeight.w900, + letterSpacing: 0.8, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/home_movie_section.dart b/lib/features/home/presentation/widgets/home_movie_section.dart index 4c3ae846..f6ae7ba7 100644 --- a/lib/features/home/presentation/widgets/home_movie_section.dart +++ b/lib/features/home/presentation/widgets/home_movie_section.dart @@ -50,17 +50,28 @@ class MovieSection extends StatelessWidget { padding: const EdgeInsets.fromLTRB(17, 18, 20, 14), child: Row( children: [ - if (isHighlighted) ...[ - Container( - width: 3, - height: 17, - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(2), + // The accent tick is on EVERY row now, not only the + // highlighted one — it is the mark that carries the chosen + // colour down the whole of Home. Highlighted rows keep their + // distinction by being taller and gradient-filled. + Container( + width: 3, + height: isHighlighted ? 19 : 15, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isHighlighted + ? [AppColors.primaryLight, AppColors.primary] + : [ + AppColors.primary, + AppColors.primary.withValues(alpha: 0.55), + ], ), + borderRadius: BorderRadius.circular(2), ), - const SizedBox(width: 10), - ], + ), + const SizedBox(width: 10), Expanded( child: Text( title, @@ -74,9 +85,11 @@ class MovieSection extends StatelessWidget { ), ), ), - const Icon( + Icon( Icons.chevron_right_rounded, - color: AppColors.textHint, + color: isHighlighted + ? AppColors.primary + : AppColors.textHint, size: 22, ), ], diff --git a/lib/features/home/presentation/widgets/home_state_views.dart b/lib/features/home/presentation/widgets/home_state_views.dart index 2e9a20de..27ee6b8b 100644 --- a/lib/features/home/presentation/widgets/home_state_views.dart +++ b/lib/features/home/presentation/widgets/home_state_views.dart @@ -241,7 +241,7 @@ class HomeErrorView extends StatelessWidget { onPressed: () => _solveCloudflare(context), style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.border), + side: BorderSide(color: AppColors.border), ), icon: const Icon(Icons.shield_outlined, size: 18), label: Text('cloudflare.solve'.tr()), diff --git a/lib/features/home/presentation/widgets/home_top_bar.dart b/lib/features/home/presentation/widgets/home_top_bar.dart index d99136fe..3678b78f 100644 --- a/lib/features/home/presentation/widgets/home_top_bar.dart +++ b/lib/features/home/presentation/widgets/home_top_bar.dart @@ -1,15 +1,15 @@ -import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; import 'dart:async'; import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; -import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/navigation/app_tab.dart'; import 'package:soplay/core/navigation/nav_controller.dart'; +import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -23,8 +23,9 @@ import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/profile/presentation/pages/profile_page.dart'; +import 'package:soplay/features/streak/data/streak_service.dart'; +import 'package:soplay/features/streak/domain/entities/streak_state.dart'; import 'package:soplay/features/streak/presentation/widgets/streak_badge.dart'; -import 'package:soplay/features/watch_party/presentation/party_entry.dart'; class HomeTopBar extends StatelessWidget { const HomeTopBar({super.key, required this.blurProgress}); @@ -36,11 +37,16 @@ class HomeTopBar extends StatelessWidget { final topPad = MediaQuery.of(context).padding.top; final progress = blurProgress.clamp(0.0, 1.0); - // Eight targets — wordmark, source pill, streak, and five actions — do not - // fit a phone at full spacing; the row overflowed by 18px on a 411dp - // screen. The space comes out of the wordmark and the icon gaps, NOT out of - // the source name: which source is live is the one piece of state in this - // bar, and a bare logo does not say it. + // The bar reports state. It does not navigate. + // + // It had grown to five permanent actions beside the wordmark, source pill + // and streak, which overflowed a 411dp phone by 18px and had to be scaled + // down to fit — a row that shrinks to survive is a row with too much in it. + // Every destination came out: search is already a default bottom tab, and + // watch party, AniList and Live TV moved to Profile, which is where a place + // you visit occasionally belongs. What is left is what TELLS you something + // without being opened — the live source, the streak, a download in flight, + // an unread count. final compact = MediaQuery.sizeOf(context).width < 430; final iconPad = compact ? 6.0 : 8.0; @@ -49,33 +55,17 @@ class HomeTopBar extends StatelessWidget { color: AppColors.textPrimary, onRefresh: () => context.read<HomeBloc>().add(HomeLoad(silent: true)), ), - _TopBarIcon( - icon: Icons.groups_rounded, - pad: iconPad, - onTap: () => showPartyEntrySheet(context), - ), - _TopBarIcon( - icon: Icons.search_rounded, - pad: iconPad, - onTap: () => getIt<NavController>().goToId(TabId.search), - ), - // AniList lived four taps deep behind the profile. Discovery and the - // airing calendar are things people open daily, and a tracker nobody - // can find is a tracker nobody connects. - _TopBarIcon.custom( - pad: iconPad, - child: const AnilistLogo(size: 19, radius: 5), - onTap: () => context.push('/anilist'), - ), - // Live TV was reachable only through the profile, which is where - // things go to be forgotten. It is a line-up you open and leave, not - // a setting. - _TopBarIcon( - icon: Icons.live_tv_rounded, - pad: iconPad, - onTap: () => context.push('/live-tv'), - ), + // Streak sits with the other status, not next to the source pill. + // Grouping the two things that report a count — a streak and an unread + // badge — puts every "here is where you stand" signal in one place and + // leaves the left side to say only what it is: the app, and the source. + const StreakBadge(), _DownloadIndicator(pad: iconPad), + _AnilistShortcut(pad: iconPad), + // Search rides next to AniList, but yields to a live streak: when the + // streak badge is showing its count the row is already full, so the + // search icon steps aside and lives where it always has — the bottom tab. + _SearchShortcut(pad: iconPad), _NotificationsIndicator(pad: iconPad), ]; @@ -98,8 +88,6 @@ class HomeTopBar extends StatelessWidget { constraints: BoxConstraints(maxWidth: compact ? 116 : 170), child: const _ProviderSwitcher(), ), - const SizedBox(width: 8), - const StreakBadge(), // The last line of defence: a transient download badge, a long streak // count or a large system font can still outgrow what is left, and a // strip that scales a few percent reads better than a yellow bar. @@ -346,7 +334,7 @@ class _ProviderQuickSwitchSheet extends StatelessWidget { ), ), ), - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), ListTile( leading: Container( width: 36, @@ -402,7 +390,7 @@ Widget _favoriteProviderTile( ), ), trailing: selected - ? const Icon(Icons.check_rounded, color: AppColors.primary, size: 20) + ? Icon(Icons.check_rounded, color: AppColors.primary, size: 20) : null, onTap: () => Navigator.of(context).pop(p.id), ); @@ -444,6 +432,88 @@ class _ProviderLogo extends StatelessWidget { } } +/// A tap target that matches the notification bell: same 24px icon, same +/// padding, so the row reads as one set of controls. +class _TopBarIconButton extends StatelessWidget { + const _TopBarIconButton({ + required this.child, + required this.onTap, + this.pad = 8, + }); + + final Widget child; + final VoidCallback onTap; + final double pad; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: onTap, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: pad, vertical: 10), + child: SizedBox(width: 24, height: 24, child: Center(child: child)), + ), + ), + ); + } +} + +class _AnilistShortcut extends StatelessWidget { + const _AnilistShortcut({this.pad = 8}); + + final double pad; + + @override + Widget build(BuildContext context) { + return _TopBarIconButton( + pad: pad, + onTap: () => context.push('/anilist'), + child: SvgPicture.asset( + 'assets/icons/anilist.svg', + width: 22, + height: 22, + ), + ); + } +} + +/// Search — but only while no streak is showing its count. A live streak fills +/// the row, so search yields and stays a bottom tab; without one it rides here. +class _SearchShortcut extends StatelessWidget { + const _SearchShortcut({this.pad = 8}); + + final double pad; + + @override + Widget build(BuildContext context) { + final streak = getIt<StreakService>(); + final loggedIn = getIt<HiveService>().isLoggedIn; + return ValueListenableBuilder<StreakState>( + valueListenable: streak.state, + builder: (context, state, _) { + final streakShowing = loggedIn && state.current > 0; + if (streakShowing) return const SizedBox.shrink(); + return _TopBarIconButton( + pad: pad, + onTap: () { + if (!getIt<NavController>().goToId(TabId.search)) { + context.push('/cross-search'); + } + }, + child: const Icon( + Icons.search_rounded, + color: Colors.white, + size: 24, + ), + ); + }, + ); + } +} + class _NotificationsIndicator extends StatefulWidget { const _NotificationsIndicator({this.pad = 8}); @@ -601,50 +671,6 @@ class _NotificationsIndicatorState extends State<_NotificationsIndicator> } } -class _TopBarIcon extends StatelessWidget { - const _TopBarIcon({ - required IconData this.icon, - required this.onTap, - this.pad = 8, - }) : child = null; - - /// For a brand mark, which is an image rather than an icon font glyph. - const _TopBarIcon.custom({ - required Widget this.child, - required this.onTap, - this.pad = 8, - }) : icon = null; - - final IconData? icon; - final Widget? child; - final VoidCallback onTap; - - /// Horizontal breathing room, tightened on phones where the row is full. - final double pad; - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: onTap, - child: Padding( - // Vertical 10 (not 8): a 40dp target was under the minimum, and - // widening it instead would overflow the row on a small phone. - padding: EdgeInsets.symmetric(horizontal: pad, vertical: 10), - // Sized to match the icons beside it, so the row stays even. - child: SizedBox( - width: 24, - height: 24, - child: child ?? Icon(icon, color: Colors.white, size: 24), - ), - ), - ), - ); - } -} - class _DownloadIndicator extends StatefulWidget { const _DownloadIndicator({this.pad = 8}); diff --git a/lib/features/home/presentation/widgets/view_all_widgets.dart b/lib/features/home/presentation/widgets/view_all_widgets.dart index f350330d..45caea06 100644 --- a/lib/features/home/presentation/widgets/view_all_widgets.dart +++ b/lib/features/home/presentation/widgets/view_all_widgets.dart @@ -120,7 +120,7 @@ class ViewAllGrid extends StatelessWidget { ), ), if (state.isLoadingMore) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( diff --git a/lib/features/link_tv/presentation/pages/link_tv_page.dart b/lib/features/link_tv/presentation/pages/link_tv_page.dart index d8b19901..2f382078 100644 --- a/lib/features/link_tv/presentation/pages/link_tv_page.dart +++ b/lib/features/link_tv/presentation/pages/link_tv_page.dart @@ -237,7 +237,7 @@ class _ScannerPlaceholder extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.qr_code_scanner, + Icon(Icons.qr_code_scanner, size: 64, color: AppColors.primary), const SizedBox(height: 16), Text('link_tv.scan_button'.tr(), @@ -494,7 +494,7 @@ class _DeviceRow extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: ListTile( - leading: const Icon(Icons.tv, color: AppColors.primary), + leading: Icon(Icons.tv, color: AppColors.primary), title: Text( device.deviceName?.isNotEmpty == true ? device.deviceName! diff --git a/lib/features/live_tv/data/live_tv_service.dart b/lib/features/live_tv/data/live_tv_service.dart index dc072f09..34addb06 100644 --- a/lib/features/live_tv/data/live_tv_service.dart +++ b/lib/features/live_tv/data/live_tv_service.dart @@ -1,6 +1,173 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +/// One programme in a channel's guide. +/// +/// Arrives two ways and is parsed once: inline on a channel as its `now` and +/// `next` slots, and as a row of `/channels/:id/epg`. The server sends the same +/// shape both times. +/// +/// Everything but the title is optional, and the title is the only thing worth +/// refusing a slot over — an entry with no name is a blank line the UI cannot +/// draw. About a third of the line-up has no guide at all, so a null +/// [LiveProgramme] is the ordinary case here, not a failure. +@immutable +class LiveProgramme { + const LiveProgramme({ + required this.title, + this.id = '', + this.start, + this.stop, + this.subtitle = '', + this.description = '', + this.category = '', + this.icon, + this.season, + this.episode, + }); + + final String id; + final String title; + + /// Episode title, tagline, or whatever the feed put in its `sub-title`. + /// Frequently empty even where the rest of the guide is complete. + final String subtitle; + final String description; + + /// The programme's own genre, which is not the channel's category: a film on + /// a news channel says "Movie" here and "News" on the channel. + final String category; + + /// Poster or still for this programme, when the feed carried one. Separate + /// from the channel logo, and absent far more often than present. + final String? icon; + + /// Both usually null, and not reliably 1-based — feeds disagree — so they are + /// carried through exactly as sent and never used in arithmetic. + final int? season; + final int? episode; + + /// Slot boundaries in local time, or null when the entry carried no usable + /// timestamp. + /// + /// Converted with [DateTime.toLocal] on the way in, so nothing above this + /// line has to remember to. Nullable independently of the title: a slot can + /// legitimately name a programme without bounding it. + final DateTime? start; + final DateTime? stop; + + /// True when there is a real window to measure against — both ends present + /// and in the right order. Everything time-shaped below is meaningless + /// without it, so ask this before drawing a clock or a bar. + bool get hasWindow => start != null && stop != null && stop!.isAfter(start!); + + Duration? get duration => hasWindow ? stop!.difference(start!) : null; + + /// How far through this slot [when] falls, from 0 to 1. + /// + /// Zero when the window is missing or nonsensical, on purpose: a bar drawn + /// empty is a bar the eye skips, while a NaN or an overrun is a bar that + /// lies. Clamped at both ends, so a stale payload whose slot already finished + /// reads as full rather than as 1.4. + double progressAt(DateTime when) { + if (!hasWindow) return 0; + final total = stop!.difference(start!).inSeconds; + if (total <= 0) return 0; + final gone = when.difference(start!).inSeconds; + if (gone <= 0) return 0; + if (gone >= total) return 1; + return gone / total; + } + + /// [progressAt] for this instant — and only for this instant. Anything + /// drawing it wants a timer, not a value cached at build time. + double get progress => progressAt(DateTime.now()); + + bool isLiveAt(DateTime when) => + hasWindow && !when.isBefore(start!) && when.isBefore(stop!); + + bool get isLive => isLiveAt(DateTime.now()); + + /// What is left of the slot, or null when it cannot be known. Never negative. + Duration? remainingAt(DateTime when) { + if (!hasWindow) return null; + final left = stop!.difference(when); + return left.isNegative ? Duration.zero : left; + } + + /// Whether a progress bar drawn for this slot would mean anything. + /// + /// XMLTV feeds pad thin guides with nine-hour "Programmes" blocks; a bar that + /// has not visibly moved since breakfast is worse than no bar. Anything under + /// five minutes is a junction or an ident and is over before it is read. + bool get isBarWorthy { + final span = duration; + return span != null && span.inMinutes >= 5 && span.inMinutes <= 360; + } + + /// `S2 E5`, `E5`, or empty. Deliberately language-neutral: the numbers are + /// the content, and any word around them is the UI's to localise. + String get episodeLabel { + final s = season; + final e = episode; + if (s != null && e != null) return 'S$s E$e'; + if (e != null) return 'E$e'; + if (s != null) return 'S$s'; + return ''; + } + + /// Null for anything that is not a usable slot: a missing key, an explicit + /// null, an empty object, or an entry with no title. + static LiveProgramme? fromJson(dynamic raw) { + if (raw is! Map) return null; + final title = _text(raw['title']); + if (title.isEmpty) return null; + final icon = _text(raw['icon']); + return LiveProgramme( + id: _text(raw['id']), + title: title, + subtitle: _text(raw['subtitle']), + description: _text(raw['description']), + category: _text(raw['category']), + icon: icon.isEmpty ? null : icon, + season: _int(raw['season']), + episode: _int(raw['episode']), + start: _time(raw['start']), + stop: _time(raw['stop']), + ); + } + + static String _text(dynamic raw) => raw == null ? '' : raw.toString().trim(); + + static int? _int(dynamic raw) { + if (raw is num) return raw.toInt(); + if (raw is String) return int.tryParse(raw.trim()); + return null; + } + + /// ISO 8601 in, local out. + /// + /// [DateTime.tryParse] returns null rather than throwing on the malformed + /// ones, keeps the zone when the string carries `Z` or an offset, and reads a + /// bare timestamp as local — which is what the server sends and what the + /// viewer means. Epoch numbers are accepted too, because feeds change their + /// minds; seconds and milliseconds are told apart by magnitude. + static DateTime? _time(dynamic raw) { + if (raw is String) { + final text = raw.trim(); + if (text.isEmpty) return null; + return DateTime.tryParse(text)?.toLocal(); + } + if (raw is num) { + final value = raw.toInt(); + if (value == 0) return null; + final ms = value.abs() < 100000000000 ? value * 1000 : value; + return DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true).toLocal(); + } + return null; + } +} + /// One live channel. @immutable class LiveChannel { @@ -12,6 +179,9 @@ class LiveChannel { this.country = '', this.language = '', this.category = '', + this.headers = const {}, + this.now, + this.next, }); final String id; @@ -21,19 +191,67 @@ class LiveChannel { final String country; final String language; + /// Headers this stream's origin insists on, or empty. + /// + /// A good share of broadcast CDNs answer 403 to a request that does not + /// present the User-Agent or Referer they expect. The server knows which + /// channels those are; without carrying them here the player asks plainly and + /// the channel looks simply broken. + final Map<String, String> headers; + /// Filled in from the group it arrived in, so a channel carries its own /// category once it is out of the list. final String category; - LiveChannel withCategory(String value) => LiveChannel( - id: id, - name: name, - streamUrl: streamUrl, - logoUrl: logoUrl, - country: country, - language: language, - category: value, - ); + /// What is on, and what follows it. Null for a channel with no guide — about + /// a third of the line-up — and null again for a channel rebuilt from a saved + /// favourite, since a slot is stale within the hour and is never worth + /// persisting. Anything reading these must draw a channel that has neither. + final LiveProgramme? now; + final LiveProgramme? next; + + /// Whether there is anything to show under the name at all. + bool get hasGuide => now != null || next != null; + + /// What to show as "on now" at [when], or null. + /// + /// The listing's `now` was correct when the page was fetched and a long + /// session outlives it, so a `now` whose window has already closed gives way + /// to `next` once `next` has actually started. A slot with no window at all + /// still counts: the feed named the programme without bounding it, and a name + /// is the part worth drawing. Null means "no guide, or a hole at this hour" — + /// the ordinary case for about 38% of the line-up. + LiveProgramme? slotAt(DateTime when) { + final current = now; + if (current != null && (!current.hasWindow || current.isLiveAt(when))) { + return current; + } + final upcoming = next; + if (upcoming != null && upcoming.hasWindow && upcoming.isLiveAt(when)) { + return upcoming; + } + return null; + } + + /// The same channel with a fresher guide, for a screen that rolls its slots + /// forward on a timer rather than re-fetching the page. + /// + /// Both arguments are positional and required so that passing nothing is not + /// a thing anyone can do by accident: this replaces the pair outright, and a + /// null means "no slot", never "leave what was there". + LiveChannel withGuide(LiveProgramme? nowSlot, LiveProgramme? nextSlot) => + LiveChannel( + id: id, + name: name, + streamUrl: streamUrl, + logoUrl: logoUrl, + country: country, + language: language, + category: category, + headers: headers, + now: nowSlot, + next: nextSlot, + ); static LiveChannel? fromJson(Map<String, dynamic> json) { final id = json['id']?.toString(); @@ -51,21 +269,25 @@ class LiveChannel { : json['logoUrl'] as String, country: json['country']?.toString() ?? '', language: json['language']?.toString() ?? '', - // Present when the channel arrives from a flat listing rather than from - // inside a group; withCategory still fills it in for the grouped shape. + // Sent with every channel the browse endpoint returns, so a channel + // carries its own category once it is out of the list. category: json['category']?.toString() ?? '', + headers: switch (json['headers']) { + final Map<dynamic, dynamic> m => { + for (final entry in m.entries) + if (entry.value != null) entry.key.toString(): entry.value.toString(), + }, + _ => const {}, + }, + // Absent for a channel with no guide, and absent from the grouped + // `/channels` shape entirely. Both stay null there, which is a state the + // rest of this class already has to survive. + now: LiveProgramme.fromJson(json['now']), + next: LiveProgramme.fromJson(json['next']), ); } } -@immutable -class LiveCategory { - const LiveCategory({required this.name, required this.channels}); - - final String name; - final List<LiveChannel> channels; -} - /// One folder in the line-up, and how much is inside it. @immutable class LiveFolder { @@ -76,6 +298,28 @@ class LiveFolder { final String? logoUrl; } +/// One country in the line-up, and how many channels come from it. +/// +/// Alongside the folders rather than instead of them: the folders answer "what +/// do I feel like watching", this answers "show me our channels", and for most +/// people opening Live TV the second question is the one they have. +@immutable +class LiveCountry { + const LiveCountry({required this.code, required this.count}); + + final String code; + final int count; +} + +/// What `/channels/categories` returns: both ways of slicing the line-up. +@immutable +class LiveIndex { + const LiveIndex({required this.folders, required this.countries}); + + final List<LiveFolder> folders; + final List<LiveCountry> countries; +} + /// One page of channels. @immutable class LivePage { @@ -90,8 +334,105 @@ class LivePage { final int page; final int total; final bool hasMore; +} + +/// One channel's guide, as `/channels/:id/epg` returns it. +/// +/// An empty schedule is a normal answer, not a failure: the guide covers around +/// 62% of the line-up, and a channel outside that still plays perfectly well. +@immutable +class LiveSchedule { + const LiveSchedule({ + required this.channelId, + required this.channelName, + required this.programmes, + }); + + final String channelId; + final String channelName; + + /// Ordered by start time, earliest first, with any untimed rows at the end. + /// Sorted here rather than trusted from the wire, because everything below + /// depends on the order and re-sorting a few dozen rows costs nothing. + final List<LiveProgramme> programmes; + + static const empty = LiveSchedule( + channelId: '', + channelName: '', + programmes: [], + ); - static const empty = LivePage(channels: [], page: 1, total: 0, hasMore: false); + bool get isEmpty => programmes.isEmpty; + bool get isNotEmpty => programmes.isNotEmpty; + + /// The slot covering [when], or null — including when the guide simply has a + /// hole at that hour, which real feeds do have. + LiveProgramme? currentAt(DateTime when) { + for (final programme in programmes) { + if (programme.isLiveAt(when)) return programme; + } + return null; + } + + /// The first slot that starts after [when], or null. + LiveProgramme? nextAfter(DateTime when) { + for (final programme in programmes) { + final start = programme.start; + if (start != null && start.isAfter(when)) return programme; + } + return null; + } + + /// What is on now, then everything still to come. What a schedule sheet + /// actually draws — yesterday's rows are noise on a screen about tonight. + List<LiveProgramme> from(DateTime when) { + return programmes.where((programme) { + if (programme.isLiveAt(when)) return true; + final start = programme.start; + return start != null && start.isAfter(when); + }).toList(growable: false); + } + + /// [fallbackId] stands in when the payload does not echo the channel back, + /// so the schedule always knows which channel it belongs to. + static LiveSchedule fromJson(dynamic raw, {String fallbackId = ''}) { + if (raw is! Map) return LiveSchedule.empty; + + var id = fallbackId; + var name = ''; + final channel = raw['channel']; + if (channel is Map) { + final rawId = channel['id']?.toString().trim() ?? ''; + if (rawId.isNotEmpty) id = rawId; + name = channel['name']?.toString().trim() ?? ''; + } else if (channel is String) { + name = channel.trim(); + } + + final rows = raw['programmes']; + final List<LiveProgramme> programmes = rows is! List + ? <LiveProgramme>[] + : rows + .map(LiveProgramme.fromJson) + .whereType<LiveProgramme>() + .toList(); + programmes.sort(_byStart); + + return LiveSchedule( + channelId: id, + channelName: name, + programmes: List<LiveProgramme>.unmodifiable(programmes), + ); + } + + static int _byStart(LiveProgramme a, LiveProgramme b) { + final x = a.start; + final y = b.start; + if (x == null && y == null) return 0; + if (x == null) return 1; + if (y == null) return -1; + return x.compareTo(y); + } } /// The live TV line-up. @@ -109,22 +450,38 @@ class LiveTvService { final Dio _dio; - /// The folders. A few dozen rows however large the line-up behind them is. - Future<List<LiveFolder>> folders() async { + /// The folders and the countries. A few dozen rows however large the line-up + /// behind them is. + Future<LiveIndex> index() async { final response = await _dio.get('/channels/categories'); - final raw = (response.data as Map?)?['categories']; - if (raw is! List) return const []; - return [ - for (final item in raw.whereType<Map>()) - if ((item['name']?.toString() ?? '').isNotEmpty) - LiveFolder( - name: item['name'].toString(), - count: (item['count'] as num?)?.toInt() ?? 0, - logoUrl: (item['logoUrl'] as String?)?.trim().isEmpty ?? true - ? null - : item['logoUrl'] as String, - ), - ]; + final data = response.data as Map?; + final raw = data?['categories']; + final rawCountries = data?['countries']; + return LiveIndex( + folders: raw is! List + ? const [] + : [ + for (final item in raw.whereType<Map>()) + if ((item['name']?.toString() ?? '').isNotEmpty) + LiveFolder( + name: item['name'].toString(), + count: (item['count'] as num?)?.toInt() ?? 0, + logoUrl: (item['logoUrl'] as String?)?.trim().isEmpty ?? true + ? null + : item['logoUrl'] as String, + ), + ], + countries: rawCountries is! List + ? const [] + : [ + for (final item in rawCountries.whereType<Map>()) + if ((item['code']?.toString() ?? '').isNotEmpty) + LiveCountry( + code: item['code'].toString(), + count: (item['count'] as num?)?.toInt() ?? 0, + ), + ], + ); } /// One page, optionally inside a folder or matching a search. @@ -134,6 +491,7 @@ class LiveTvService { /// megabytes with extra steps. Future<LivePage> browse({ String? category, + String? country, String? search, int page = 1, int limit = 40, @@ -144,6 +502,7 @@ class LiveTvService { 'page': page, 'limit': limit, if (category != null && category.isNotEmpty) 'category': category, + if (country != null && country.isNotEmpty) 'country': country, if (search != null && search.trim().isNotEmpty) 'search': search.trim(), }, ); @@ -163,26 +522,26 @@ class LiveTvService { ); } - Future<List<LiveCategory>> lineup() async { - final response = await _dio.get('/channels'); - final categories = (response.data as Map?)?['categories']; - if (categories is! List) return const []; - - final out = <LiveCategory>[]; - for (final raw in categories.whereType<Map>()) { - final name = raw['name']?.toString() ?? 'general'; - final list = raw['channels']; - if (list is! List) continue; - final channels = list - .whereType<Map>() - .map((e) => LiveChannel.fromJson(e.cast<String, dynamic>())) - .whereType<LiveChannel>() - .map((c) => c.withCategory(name)) - .toList(growable: false); - if (channels.isNotEmpty) { - out.add(LiveCategory(name: name, channels: channels)); - } - } - return out; + /// One channel's guide, [hours] ahead. + /// + /// Separate from [browse] on purpose: a page of forty channels does not want + /// forty schedules hanging off it, so the listing carries only the two slots + /// a tile can show and the full day is read once, for the one channel + /// somebody actually opened. + /// + /// Returns an empty schedule for a channel with no guide, or for a response + /// shaped in a way this does not recognise. Network and HTTP failures throw, + /// as they do everywhere else in this service — the caller decides whether a + /// missing guide is worth saying out loud, and on this screen it is not. + Future<LiveSchedule> schedule(String channelId, {int hours = 24}) async { + final id = channelId.trim(); + if (id.isEmpty) return LiveSchedule.empty; + final response = await _dio.get( + // Encoded because channel ids come from the feed rather than from us and + // routinely contain dots, and occasionally worse. + '/channels/${Uri.encodeComponent(id)}/epg', + queryParameters: {'hours': hours.clamp(1, 168).toInt()}, + ); + return LiveSchedule.fromJson(response.data, fallbackId: id); } } diff --git a/lib/features/live_tv/presentation/pages/live_tv_page.dart b/lib/features/live_tv/presentation/pages/live_tv_page.dart index 2c6653fc..2254313f 100644 --- a/lib/features/live_tv/presentation/pages/live_tv_page.dart +++ b/lib/features/live_tv/presentation/pages/live_tv_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -7,9 +8,28 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/live_tv/data/live_tv_service.dart'; +import 'package:soplay/features/live_tv/presentation/widgets/channel_sheet.dart'; + +const double _kGutter = 14; +const double _kSpacing = 10; + +int _columnsFor(double width) => width >= 900 ? 5 : (width >= 620 ? 4 : 3); + +/// Width factor for a channel card's progress hairline, or null for no bar. +/// +/// The 0.02 floor stops a slot that started a minute ago rendering as a +/// zero-width sliver, which reads as a paint bug rather than as a beginning. +double? _barFactor(LiveProgramme? slot, DateTime at) { + if (slot == null || !slot.isBarWorthy) return null; + final value = slot.progressAt(at); // already clamped to 0..1 by the model + if (value <= 0) return null; + return value < 0.02 ? 0.02 : value; +} /// Live TV. /// @@ -34,23 +54,50 @@ class _LiveTvPageState extends State<LiveTvPage> { final _scroll = ScrollController(); List<LiveFolder> _folders = const []; + List<LiveCountry> _countries = const []; List<LiveChannel> _channels = const []; Set<String> _favourites = <String>{}; List<String> _recent = const []; Map<String, Map<String, String>> _cards = {}; + /// Pinned channels, rebuilt only when the pins change — never in build(). + List<LiveChannel> _favouriteCards = const []; + List<LiveChannel> _recentCards = const []; + /// The folder being read, or empty for the top level. String _folder = ''; + + /// The country being read, or empty. Mutually exclusive with [_folder] — + /// they are two ways of asking the same question and combining them produces + /// a screen nobody navigated to. + String _country = ''; String _query = ''; Timer? _debounce; + Timer? _ticker; int _page = 1; bool _hasMore = false; + + /// How many channels the OPEN scope has, as browse reports it. int _total = 0; - bool _loading = true; + + /// The whole line-up, summed from the folder counts. A different number with + /// a different meaning, and the one the Categories header wants. + int _indexTotal = 0; + + /// The index request has finished, succeeded or not. + bool _booted = false; + bool _loading = false; bool _loadingMore = false; String? _error; + /// Request generation. Bumped by every scope change, and checked after every + /// await, so a response for a folder the user already left is dropped. + int _seq = 0; + + /// One "now" per tick, so every bar and slot line on screen agrees. + DateTime _now = DateTime.now(); + @override void initState() { super.initState(); @@ -58,52 +105,80 @@ class _LiveTvPageState extends State<LiveTvPage> { _favourites = hive.getLiveTvFavourites().toSet(); _recent = hive.getLiveTvRecent(); _cards = hive.getLiveTvCards(); + _rebuildPins(); _scroll.addListener(_onScroll); - _loadFolders(); + _ticker = Timer.periodic(const Duration(seconds: 60), _onTick); + _loadIndex(); } @override void dispose() { _debounce?.cancel(); + _ticker?.cancel(); _scroll.removeListener(_onScroll); _scroll.dispose(); _search.dispose(); super.dispose(); } - /// True while the screen is showing folders rather than channels. - bool get _atTopLevel => _folder.isEmpty && _query.trim().isEmpty; + void _onTick(Timer _) { + if (!mounted) return; + // The tab lives inside main_page's IndexedStack for the whole session, so + // this refuses to rebuild anything that has no bar on it. + if (!_scoped) return; + if (!_channels.any((c) => c.now != null)) return; + setState(() => _now = DateTime.now()); + } - Future<void> _loadFolders() async { - setState(() { - _loading = true; - _error = null; - }); + /// True while a folder, a country or a search is open — i.e. whenever the + /// screen is showing channels rather than the line-up's index. + bool get _scoped => + _folder.isNotEmpty || _country.isNotEmpty || _query.trim().isNotEmpty; + + bool get _searching => _query.trim().isNotEmpty; + + String get _scopeName { + if (_folder.isNotEmpty) return _folder; + if (_country.isNotEmpty) return _countryName(_country); + return 'live_tv.results'.tr(); + } + + Future<void> _loadIndex({bool silent = false}) async { + if (!silent) setState(() => _error = null); try { - final folders = await _service.folders(); + final index = await _service.index(); if (!mounted) return; + // Ordered here, once, and by the same rule for both strips: the line-up + // leads with what it actually carries. + final folders = [...index.folders] + ..sort((a, b) => b.count.compareTo(a.count)); + final countries = [...index.countries] + ..sort((a, b) => b.count.compareTo(a.count)); setState(() { _folders = folders; - _total = folders.fold(0, (n, f) => n + f.count); - _loading = false; + _countries = countries; + _indexTotal = folders.fold(0, (n, f) => n + f.count); + _booted = true; + _error = null; }); } catch (_) { if (!mounted) return; setState(() { - _loading = false; + _booted = true; _error = 'live_tv.load_failed'.tr(); }); } } - /// One page of channels for the open folder, or for the current search. - Future<void> _loadPage({bool append = false}) async { + /// One page of channels for the open scope. + Future<void> _loadPage({bool append = false, bool silent = false}) async { if (append && (_loadingMore || !_hasMore)) return; + final seq = ++_seq; setState(() { if (append) { _loadingMore = true; } else { - _loading = true; + if (!silent) _loading = true; _error = null; } }); @@ -111,106 +186,254 @@ class _LiveTvPageState extends State<LiveTvPage> { try { final result = await _service.browse( category: _folder.isEmpty ? null : _folder, + country: _country.isEmpty ? null : _country, search: _query, page: page, ); - if (!mounted) return; + if (!mounted || seq != _seq) return; setState(() { - _channels = append ? [..._channels, ...result.channels] : result.channels; + _channels = append + ? [..._channels, ...result.channels] + : result.channels; _page = result.page; _hasMore = result.hasMore; _total = result.total; _loading = false; _loadingMore = false; + _error = null; }); _rememberCards(result.channels); } catch (_) { - if (!mounted) return; + if (!mounted || seq != _seq) return; + final hasContent = _channels.isNotEmpty; setState(() { _loading = false; _loadingMore = false; - if (!append) _error = 'live_tv.load_failed'.tr(); + if (!hasContent) _error = 'live_tv.load_failed'.tr(); }); + // With a grid already on screen there is nowhere to put an error state, + // and stopping without a word looks like the list simply ended. + if (hasContent) _toast('live_tv.load_failed'.tr()); } } - /// The next page, once the list is close enough to its end to need one. - void _onScroll() { - if (!_scroll.hasClients || _atTopLevel) return; - final position = _scroll.position; - if (position.pixels >= position.maxScrollExtent - 600) _loadPage(append: true); + void _toast(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } - /// Searching goes to the server, so it waits for a pause in the typing. - void _onQueryChanged(String value) { - _debounce?.cancel(); - setState(() => _query = value); - if (value.trim().isEmpty) { - // Back to whatever was on screen before the search started. - if (_folder.isEmpty) { - setState(() => _channels = const []); - } else { - _loadPage(); - } - return; - } - _debounce = Timer(const Duration(milliseconds: 350), _loadPage); + /// Pull to refresh. Silent because RefreshIndicator draws its own spinner and + /// raising [_loading] would swap the grid for skeletons underneath it. + Future<void> _refresh() => + _scoped ? _loadPage(silent: true) : _loadIndex(silent: true); + + /// Everything a scope change must forget. Always called inside a setState. + void _resetPaging() { + _page = 1; + _hasMore = false; + _total = 0; + _error = null; + _channels = const []; } void _openFolder(String name) { setState(() { _folder = name; - _channels = const []; + _country = ''; + _resetPaging(); }); if (_scroll.hasClients) _scroll.jumpTo(0); _loadPage(); } - void _closeFolder() { + void _openCountry(String code) { setState(() { + _country = code; _folder = ''; - _channels = const []; - _query = ''; + _resetPaging(); }); + if (_scroll.hasClients) _scroll.jumpTo(0); + _loadPage(); + } + + void _closeScope() { + _debounce?.cancel(); _search.clear(); + _seq++; // drop anything already in flight for the scope being left + setState(() { + _folder = ''; + _country = ''; + _query = ''; + _resetPaging(); + }); if (_scroll.hasClients) _scroll.jumpTo(0); } + /// Searching goes to the server, so it waits for a pause in the typing. + void _onQueryChanged(String value) { + _debounce?.cancel(); + setState(() { + _query = value; + _resetPaging(); + }); + if (value.trim().isEmpty) { + if (_folder.isEmpty && _country.isEmpty) { + _seq++; // back to the top level; nothing to fetch, nothing in flight + return; + } + // Still inside a folder or a country: the scope itself is what to show. + _loadPage(); + return; + } + _debounce = Timer(const Duration(milliseconds: 350), _loadPage); + } + + /// The next page, once the list is close enough to its end to need one. + void _onScroll() { + if (!_scroll.hasClients || !_scoped) return; + if (_loading || _loadingMore || !_hasMore) return; + final position = _scroll.position; + if (position.pixels >= position.maxScrollExtent - 600) { + _loadPage(append: true); + } + } + + /// Rebuilds the two pinned rails from the saved cards. + void _rebuildPins() { + final favourites = <LiveChannel>[]; + for (final id in _favourites) { + final channel = _fromCard(id); + if (channel != null) favourites.add(channel); + } + final recents = <LiveChannel>[]; + for (final id in _recent) { + if (_favourites.contains(id)) continue; + final channel = _fromCard(id); + if (channel != null) recents.add(channel); + } + _favouriteCards = favourites.take(12).toList(growable: false); + _recentCards = recents.take(12).toList(growable: false); + } + /// Keeps enough of the pinned channels to draw them without their page. void _rememberCards(List<LiveChannel> seen) { final wanted = {..._favourites, ..._recent}; - if (wanted.isEmpty) return; var changed = false; for (final channel in seen) { if (!wanted.contains(channel.id)) continue; - final card = { - 'name': channel.name, - 'streamUrl': channel.streamUrl, - 'logoUrl': channel.logoUrl ?? '', - 'category': channel.category, - }; + final card = _cardOf(channel); if (_cards[channel.id]?.toString() == card.toString()) continue; _cards[channel.id] = card; changed = true; } // Only what is still pinned; a cache that only grows is a leak with a nicer // name. + final before = _cards.length; _cards.removeWhere((id, _) => !wanted.contains(id)); - if (changed) getIt<HiveService>().setLiveTvCards(_cards); + if (_cards.length != before) changed = true; + if (!changed) return; + getIt<HiveService>().setLiveTvCards(_cards); + if (!mounted) return; + setState(_rebuildPins); + } + + /// A country code as something readable, in English. + /// + /// English because the channel names beside it are: a strip reading + /// "O'zbekiston · Rossiya" above a grid of "Pluto TV Comedy" and "Al Jazeera" + /// is two languages doing one job. Only the countries this line-up actually + /// carries are named; anything else keeps its code, which still beats a blank. + static const Map<String, String> _countryNames = { + 'UZ': 'Uzbekistan', + 'RU': 'Russia', + 'TR': 'Turkey', + 'KZ': 'Kazakhstan', + 'KG': 'Kyrgyzstan', + 'TJ': 'Tajikistan', + 'TM': 'Turkmenistan', + 'AZ': 'Azerbaijan', + 'US': 'United States', + 'UK': 'United Kingdom', + 'GB': 'United Kingdom', + 'CA': 'Canada', + 'AU': 'Australia', + 'DE': 'Germany', + 'FR': 'France', + 'ES': 'Spain', + 'IT': 'Italy', + 'QA': 'Qatar', + 'AE': 'UAE', + 'SA': 'Saudi Arabia', + 'CN': 'China', + 'JP': 'Japan', + 'KR': 'South Korea', + 'IN': 'India', + 'SG': 'Singapore', + 'IL': 'Israel', + 'AT': 'Austria', + 'SK': 'Slovakia', + 'SE': 'Sweden', + 'NL': 'Netherlands', + 'PL': 'Poland', + 'UA': 'Ukraine', + 'GE': 'Georgia', + 'BR': 'Brazil', + 'MX': 'Mexico', + }; + + String _countryName(String code) => + _countryNames[code.toUpperCase()] ?? code.toUpperCase(); + + /// The flag for an ISO country code, built from regional-indicator letters. + /// + /// A picture rather than two letters, and no asset to ship or fail to load. + String _flagOf(String code) { + final c = code.toUpperCase(); + if (c.length != 2 || !RegExp(r'^[A-Z]{2}$').hasMatch(c)) return ''; + return String.fromCharCodes([ + 0x1F1E6 + c.codeUnitAt(0) - 0x41, + 0x1F1E6 + c.codeUnitAt(1) - 0x41, + ]); } + /// Everything needed to draw and PLAY a pinned channel without its page. + /// + /// `headers` rides along encoded, because a favourite is opened straight from + /// this cache: dropping them here meant a header-gated channel worked the + /// first time and 403'd every time after it was pinned. + Map<String, String> _cardOf(LiveChannel channel) => { + 'name': channel.name, + 'streamUrl': channel.streamUrl, + 'logoUrl': channel.logoUrl ?? '', + 'category': channel.category, + if (channel.headers.isNotEmpty) 'headers': jsonEncode(channel.headers), + }; + LiveChannel? _fromCard(String id) { final card = _cards[id]; if (card == null) return null; final url = card['streamUrl'] ?? ''; final name = card['name'] ?? ''; if (url.isEmpty || name.isEmpty) return null; + Map<String, String> headers = const {}; + final raw = card['headers']; + if (raw != null && raw.isNotEmpty) { + try { + headers = (jsonDecode(raw) as Map).map( + (k, v) => MapEntry(k.toString(), v.toString()), + ); + } catch (_) { + // A card written by an older build, or corrupted. Playing without the + // headers is worth trying; refusing to draw the channel is not. + } + } return LiveChannel( id: id, name: name, streamUrl: url, logoUrl: (card['logoUrl'] ?? '').isEmpty ? null : card['logoUrl'], category: card['category'] ?? '', + headers: headers, ); } @@ -218,13 +441,9 @@ class _LiveTvPageState extends State<LiveTvPage> { setState(() { if (!_favourites.remove(channel.id)) { _favourites.add(channel.id); - _cards[channel.id] = { - 'name': channel.name, - 'streamUrl': channel.streamUrl, - 'logoUrl': channel.logoUrl ?? '', - 'category': channel.category, - }; + _cards[channel.id] = _cardOf(channel); } + _rebuildPins(); }); final hive = getIt<HiveService>(); hive.setLiveTvFavourites(_favourites.toList()); @@ -234,24 +453,23 @@ class _LiveTvPageState extends State<LiveTvPage> { void _play(LiveChannel channel) { final hive = getIt<HiveService>(); hive.pushLiveTvRecent(channel.id); - _cards[channel.id] = { - 'name': channel.name, - 'streamUrl': channel.streamUrl, - 'logoUrl': channel.logoUrl ?? '', - 'category': channel.category, - }; + _cards[channel.id] = _cardOf(channel); hive.setLiveTvCards(_cards); setState(() { _recent = [channel.id, ..._recent.where((e) => e != channel.id)] .take(12) .toList(); + _rebuildPins(); }); context.push( '/player', extra: PlayerArgs( title: channel.name, provider: 'live', - headers: const {}, + // Some broadcast CDNs 403 anything that does not present the + // User-Agent or Referer they expect. The server records which channels + // those are; sending an empty map made them look dead. + headers: channel.headers, movieUrl: channel.streamUrl, thumbnail: channel.logoUrl, // Live has no episodes and nothing to resume to, and offering a @@ -262,6 +480,16 @@ class _LiveTvPageState extends State<LiveTvPage> { ); } + Future<void> _openSheet(LiveChannel channel) { + return showChannelSheet( + context: context, + channel: channel, + favourite: _favourites.contains(channel.id), + onPlay: () => _play(channel), + onToggleFavourite: () => _toggleFavourite(channel), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -272,201 +500,459 @@ class _LiveTvPageState extends State<LiveTvPage> { scrolledUnderElevation: 0, elevation: 0, automaticallyImplyLeading: !widget.embedded, - titleSpacing: widget.embedded ? 18 : null, - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'live_tv.title'.tr(), - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800), + titleSpacing: widget.embedded ? _kGutter : null, + // Inside a folder, a country or a search, back closes the scope rather + // than the screen; outside one this is null and the usual leading + // behaviour applies. + leading: _scoped + ? IconButton( + icon: const Icon(Icons.arrow_back_rounded, size: 22), + color: AppColors.textPrimary, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: _closeScope, + ) + : null, + title: Text( + 'live_tv.title'.tr(), + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + ), + body: SafeArea( + // Inside the SafeArea and outside the RefreshIndicator, so the width + // every grid divides up is the real content width — a notch in + // landscape makes it several points narrower than the screen. + child: LayoutBuilder( + builder: (context, constraints) => RefreshIndicator( + color: AppColors.primary, + backgroundColor: AppColors.surface, + onRefresh: _refresh, + child: CustomScrollView( + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + slivers: _slivers(constraints.maxWidth), ), - const SizedBox(width: 8), - const _LivePill(), - ], + ), ), ), - body: SafeArea(child: _body()), ); } - Widget _body() { - if (_loading && _folders.isEmpty && _channels.isEmpty) { - return const Center(child: CircularProgressIndicator(strokeWidth: 2.5)); - } - if (_error != null && _folders.isEmpty && _channels.isEmpty) { - return _Empty( - icon: Icons.cloud_off_rounded, - text: _error!, - actionLabel: 'live_tv.retry'.tr(), - onAction: _loadFolders, - ); - } - - final searching = _query.trim().isNotEmpty; - final favourites = [ - for (final id in _favourites) - if (_fromCard(id) != null) _fromCard(id)!, - ]; - final recent = [ - for (final id in _recent) - if (!_favourites.contains(id) && _fromCard(id) != null) _fromCard(id)!, + /// Every state of this screen is a sliver under the search field, so the + /// field is never unmounted underneath somebody's typing. + List<Widget> _slivers(double width) { + return [ + _searchSliver(), + if (_scoped) ..._scopedSlivers(width) else ..._topSlivers(width), + SliverToBoxAdapter(child: SizedBox(height: widget.embedded ? 96 : 28)), ]; + } - return RefreshIndicator( - color: AppColors.primary, - backgroundColor: AppColors.surface, - onRefresh: _atTopLevel ? _loadFolders : () => _loadPage(), - child: CustomScrollView( - controller: _scroll, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 10), - child: TextField( - controller: _search, - onChanged: _onQueryChanged, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - isDense: true, - hintText: _folder.isEmpty - ? 'live_tv.search_hint'.tr() - : 'live_tv.search_in'.tr(namedArgs: {'folder': _folder}), - prefixIcon: const Icon(Icons.search_rounded, size: 20), - suffixIcon: _query.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.close_rounded, size: 18), - onPressed: () { - _search.clear(); - _onQueryChanged(''); - }, - ), - ), - ), + Widget _searchSliver() { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(_kGutter, 8, _kGutter, 10), + child: TextField( + controller: _search, + onChanged: _onQueryChanged, + textInputAction: TextInputAction.search, + style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), + // Fill, padding and radius all come from inputDecorationTheme, so + // this field is the same field as every other one in the app. + decoration: InputDecoration( + hintText: (_folder.isEmpty && _country.isEmpty) + ? 'live_tv.search_hint'.tr() + : 'live_tv.search_in'.tr(namedArgs: {'folder': _scopeName}), + prefixIcon: const Icon( + Icons.search_rounded, + size: 20, + color: AppColors.textSecondary, ), + suffixIcon: _query.isEmpty + ? null + : IconButton( + icon: const Icon( + Icons.close_rounded, + size: 18, + color: AppColors.textSecondary, + ), + onPressed: () { + _search.clear(); + _onQueryChanged(''); + }, + ), ), + ), + ), + ); + } - // Which folder is open, and the way back out of it. - if (_folder.isNotEmpty) - SliverToBoxAdapter( - child: _FolderCrumb( - folder: _folder, - total: _total, - onBack: _closeFolder, - ), + List<Widget> _topSlivers(double width) { + final indexEmpty = _booted && _folders.isEmpty; + + return [ + if (_recentCards.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.history_rounded, + label: 'live_tv.recent'.tr(), + ), + _PinRail( + channels: _recentCards, + favourites: _favourites, + onPlay: _play, + onMore: _openSheet, + ), + ], + if (_favouriteCards.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.star_rounded, + label: 'live_tv.favourites'.tr(), + ), + _PinRail( + channels: _favouriteCards, + favourites: _favourites, + onPlay: _play, + onMore: _openSheet, + ), + ], + if (_booted && + _recentCards.isEmpty && + _favouriteCards.isEmpty && + _folders.isNotEmpty) + const _HintLine(), + if (indexEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _Empty( + icon: _error != null + ? Icons.cloud_off_rounded + : Icons.live_tv_rounded, + text: _error ?? 'live_tv.empty'.tr(), + actionLabel: 'live_tv.retry'.tr(), + onAction: () => _loadIndex(), + ), + ) + else ...[ + _SectionHeader( + icon: Icons.grid_view_rounded, + label: 'live_tv.categories'.tr(), + trailing: _indexTotal > 0 + ? 'live_tv.channel_count'.plural(_indexTotal) + : null, + ), + if (!_booted) + _CategorySkeleton(width: width) + else + _CategoryGrid(folders: _folders, width: width, onOpen: _openFolder), + if (!_booted || _countries.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.public_rounded, + label: 'live_tv.countries'.tr(), + ), + if (!_booted) + const _CountryRailSkeleton() + else + _CountryRail( + countries: _countries, + nameOf: _countryName, + flagOf: _flagOf, + onOpen: _openCountry, ), + ], + ], + ]; + } - if (_atTopLevel) ...[ - if (recent.isNotEmpty) ...[ - _Header(label: 'live_tv.recent'.tr()), - _RecentRow(channels: recent, onPlay: _play), - ], - if (favourites.isNotEmpty) ...[ - _Header(label: 'live_tv.favourites'.tr()), - _Grid( - channels: favourites, - favourites: _favourites, - onPlay: _play, - onFavourite: _toggleFavourite, - ), - ], - _Header(label: 'live_tv.folders'.tr()), - // Folders, not channels. A hundred thousand channels is a few dozen - // folders, and the one somebody wants is two taps away rather than - // twenty megabytes and a scroll. - _FolderGrid(folders: _folders, onOpen: _openFolder), - ] else if (_loading) ...[ - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.only(top: 60), - child: Center(child: CircularProgressIndicator(strokeWidth: 2.5)), + List<Widget> _scopedSlivers(double width) { + return [ + _ScopeLine( + name: _scopeName, + total: _total, + showTotal: _channels.isNotEmpty, + ), + if (_loading && _channels.isEmpty) + _GridSkeleton(width: width, count: 9) + else if (_error != null && _channels.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _Empty( + icon: Icons.cloud_off_rounded, + text: _error!, + actionLabel: 'live_tv.retry'.tr(), + onAction: () => _loadPage(), + ), + ) + else if (_channels.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _Empty( + icon: _searching ? Icons.search_off_rounded : Icons.live_tv_rounded, + text: _searching ? 'live_tv.no_match'.tr() : 'live_tv.empty'.tr(), + ), + ) + else + _ChannelGrid( + channels: _channels, + favourites: _favourites, + width: width, + now: _now, + onPlay: _play, + onMore: _openSheet, + ), + if (_loadingMore) const _LoadMoreFooter(), + ]; + } +} + +/// A section title, in Home's one header shape. +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.icon, + required this.label, + this.trailing, + }); + + final IconData icon; + final String label; + final String? trailing; + + @override + Widget build(BuildContext context) { + final tail = trailing; + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(_kGutter, 18, _kGutter, 12), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.textSecondary), + const SizedBox(width: 8), + Semantics( + header: true, + child: Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w800, + height: 1.1, + ), ), ), - ] else if (_channels.isEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: _Empty( - icon: searching ? Icons.search_off_rounded : Icons.live_tv_rounded, - text: searching ? 'live_tv.no_match'.tr() : 'live_tv.empty'.tr(), + const Spacer(), + if (tail != null) + Text( + tail, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +/// What is open, and how much is in it. The way back out is the AppBar's, which +/// is on screen at every scroll position. +class _ScopeLine extends StatelessWidget { + const _ScopeLine({ + required this.name, + required this.total, + required this.showTotal, + }); + + final String name; + final int total; + final bool showTotal; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(_kGutter, 2, _kGutter, 10), + child: Row( + children: [ + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, ), ), ), - ] else ...[ - _Grid( - channels: _channels, - favourites: _favourites, - onPlay: _play, - onFavourite: _toggleFavourite, - ), - if (_loadingMore) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 18), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2.2), - ), - ), + if (showTotal) ...[ + const SizedBox(width: 10), + Text( + 'live_tv.channel_count'.plural(total), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w700, ), ), + ], ], + ), + ), + ); + } +} - SliverToBoxAdapter(child: SizedBox(height: widget.embedded ? 96 : 28)), - ], +/// The one line that teaches the long press, shown only while nothing is +/// pinned — the moment there is a pin, it has been learnt. +class _HintLine extends StatelessWidget { + const _HintLine(); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(_kGutter, 0, _kGutter, 4), + child: Text( + 'live_tv.hint_long_press'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + height: 1.35, + ), + ), ), ); } } -/// The folder you are inside, and the way back out. -class _FolderCrumb extends StatelessWidget { - const _FolderCrumb({ - required this.folder, - required this.total, - required this.onBack, +/// Recents and favourites, in one shape. +/// +/// A rail because these are shortcuts, not a section to browse: they should +/// never push the line-up itself off the first screen. +class _PinRail extends StatelessWidget { + const _PinRail({ + required this.channels, + required this.favourites, + required this.onPlay, + required this.onMore, }); - final String folder; - final int total; - final VoidCallback onBack; + final List<LiveChannel> channels; + final Set<String> favourites; + final ValueChanged<LiveChannel> onPlay; + final ValueChanged<LiveChannel> onMore; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 6), - child: Row( + // 76 tile + 6 gap + one caption line, so text scaling cannot overflow it. + final height = 82 + MediaQuery.textScalerOf(context).scale(10.5) * 1.2; + return SliverToBoxAdapter( + child: SizedBox( + height: height, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + itemCount: channels.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (context, i) => _PinTile( + channel: channels[i], + favourite: favourites.contains(channels[i].id), + onPlay: onPlay, + onMore: onMore, + ), + ), + ), + ); + } +} + +/// One pinned channel: its logo, and its name underneath. +/// +/// No guide line here on purpose — a pinned channel is rebuilt from a saved +/// card, which never carries a slot, so the line would be permanently blank. +class _PinTile extends StatelessWidget { + const _PinTile({ + required this.channel, + required this.favourite, + required this.onPlay, + required this.onMore, + }); + + final LiveChannel channel; + final bool favourite; + final ValueChanged<LiveChannel> onPlay; + final ValueChanged<LiveChannel> onMore; + + @override + Widget build(BuildContext context) { + final dpr = MediaQuery.devicePixelRatioOf(context); + return SizedBox( + width: 76, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ Material( - color: AppColors.surface, - borderRadius: BorderRadius.circular(10), + color: AppColors.card, + borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: onBack, - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 7), - child: Icon(Icons.arrow_back_rounded, size: 17), + onTap: () => onPlay(channel), + onLongPress: () => onMore(channel), + onSecondaryTap: () => onMore(channel), + child: Container( + width: 76, + height: 76, + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: favourite + ? AppColors.primary.withValues(alpha: 0.45) + : Colors.white.withValues(alpha: 0.06), + ), + ), + child: channel.logoUrl == null + ? const Icon( + Icons.live_tv_rounded, + size: 26, + color: AppColors.textHint, + ) + : CachedNetworkImage( + imageUrl: channel.logoUrl!, + fit: BoxFit.contain, + // The 54pt content box, not the 76pt tile. + memCacheWidth: (54 * dpr).round(), + errorWidget: (_, _, _) => const Icon( + Icons.live_tv_rounded, + size: 26, + color: AppColors.textHint, + ), + placeholder: (_, _) => const SizedBox.shrink(), + ), ), ), ), - const SizedBox(width: 10), - Expanded( + const SizedBox(height: 6), + FixedTextLines( + fontSize: 10.5, + lineHeight: 1.2, + lines: 1, child: Text( - folder, + channel.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), - ), - ), - Text( - '$total', - style: const TextStyle( - fontSize: 11.5, - fontWeight: FontWeight.w700, - color: AppColors.textHint, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), ), ), ], @@ -475,217 +961,215 @@ class _FolderCrumb extends StatelessWidget { } } -/// The folders themselves. +/// The categories, two per row. /// -/// Two per row and captioned with a count, because the decision being made here -/// is "which of these do I want", and a count is the only thing that -/// distinguishes a folder with four channels in it from one with four thousand. -class _FolderGrid extends StatelessWidget { - const _FolderGrid({required this.folders, required this.onOpen}); +/// Captioned with a count, because the decision being made here is "which of +/// these do I want", and a count is the only thing that distinguishes a +/// category with four channels in it from one with four hundred. +class _CategoryGrid extends StatelessWidget { + const _CategoryGrid({ + required this.folders, + required this.width, + required this.onOpen, + }); final List<LiveFolder> folders; + final double width; final ValueChanged<String> onOpen; + /// The eleven names the line-up actually uses, each with the glyph somebody + /// would draw for it. Anything else the backend grows later falls back. + static const Map<String, IconData> _glyphs = { + 'movies': Icons.movie_rounded, + 'general': Icons.tv_rounded, + 'entertainment': Icons.celebration_rounded, + 'kids': Icons.child_care_rounded, + 'documentary': Icons.public_rounded, + 'news': Icons.article_rounded, + 'music': Icons.music_note_rounded, + 'sports': Icons.sports_soccer_rounded, + 'lifestyle': Icons.spa_rounded, + 'religious': Icons.auto_stories_rounded, + 'family': Icons.family_restroom_rounded, + }; + + static IconData _glyphFor(String name) => + _glyphs[name.toLowerCase()] ?? Icons.folder_rounded; + + static int columnsFor(double width) => + width >= 900 ? 4 : (width >= 620 ? 3 : 2); + + /// Two lines of text plus the tile's own padding, floored at the height the + /// tile has always had — so it is unchanged at normal text size and grows + /// rather than overflows at 1.8×. + static double extentFor(BuildContext context) { + final ts = MediaQuery.textScalerOf(context); + final raw = ts.scale(12.5) * 1.2 + 3 + ts.scale(10.5) * 1.2 + 28; + return raw < 64 ? 64.0 : raw; + } + @override Widget build(BuildContext context) { - final width = MediaQuery.sizeOf(context).width; - final columns = width >= 900 ? 4 : (width >= 620 ? 3 : 2); - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: _kGutter), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - mainAxisExtent: 64, + crossAxisCount: columnsFor(width), + mainAxisSpacing: _kSpacing, + crossAxisSpacing: _kSpacing, + mainAxisExtent: extentFor(context), ), - delegate: SliverChildBuilderDelegate( - (context, i) { - final folder = folders[i]; - return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(14), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => onOpen(folder.name), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + delegate: SliverChildBuilderDelegate((context, i) { + final folder = folders[i]; + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onOpen(folder.name), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.white.withValues(alpha: 0.06), ), - child: Row( - children: [ - SizedBox( - width: 32, - height: 32, - child: folder.logoUrl == null - ? Icon( - Icons.folder_rounded, - size: 19, - color: AppColors.textHint, - ) - : CachedNetworkImage( - imageUrl: folder.logoUrl!, - fit: BoxFit.contain, - errorWidget: (_, _, _) => Icon( - Icons.folder_rounded, - size: 19, - color: AppColors.textHint, - ), - ), + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - folder.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w700, - ), + child: Icon( + _glyphFor(folder.name), + size: 18, + color: AppColors.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + folder.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12.5, + fontWeight: FontWeight.w700, + height: 1.2, ), - const SizedBox(height: 2), - Text( - 'live_tv.channel_count'.plural(folder.count), - style: const TextStyle( - fontSize: 10.5, - color: AppColors.textHint, - ), + ), + const SizedBox(height: 3), + Text( + 'live_tv.channel_count'.plural(folder.count), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + height: 1.2, ), - ], - ), - ), - Icon( - Icons.chevron_right_rounded, - size: 18, - color: AppColors.textHint, + ), + ], ), - ], - ), + ), + ], ), ), - ); - }, - childCount: folders.length, - ), - ), - ); - } -} - -class _LivePill extends StatelessWidget { - const _LivePill(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.16), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 5, - height: 5, - decoration: const BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, ), - ), - const SizedBox(width: 5), - Text( - 'LIVE', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w900, - letterSpacing: 0.9, - color: AppColors.primary, - ), - ), - ], + ); + }, childCount: folders.length), ), ); } } -/// The last few channels, as a rail rather than a grid. +/// The countries the line-up covers, as a strip you scroll sideways. /// -/// A rail because this is a shortcut, not a section to browse: four or five -/// entries wide is the whole of it, and it should never push the line-up itself -/// off the first screen. -class _RecentRow extends StatelessWidget { - const _RecentRow({required this.channels, required this.onPlay}); +/// A shortcut for somebody who already knows what they are after, not the main +/// navigation. Ordered by how much each country contributes, and cut at +/// sixteen — past that the codes stop resolving to names and the strip goes +/// ragged. +class _CountryRail extends StatelessWidget { + const _CountryRail({ + required this.countries, + required this.nameOf, + required this.flagOf, + required this.onOpen, + }); - final List<LiveChannel> channels; - final ValueChanged<LiveChannel> onPlay; + static const int _cap = 16; + + final List<LiveCountry> countries; + final String Function(String) nameOf; + final String Function(String) flagOf; + final ValueChanged<String> onOpen; @override Widget build(BuildContext context) { + // Regional-indicator pairs do not render on Windows, where a flag becomes + // two letterboxed letters. Phones, where this strip is actually used, get + // the picture. + final showFlags = !isDesktopPlatform; + return SliverToBoxAdapter( child: SizedBox( - height: 62, + height: 40, child: ListView.separated( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 14), - itemCount: channels.length, + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + itemCount: countries.length < _cap ? countries.length : _cap, separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (context, i) { - final channel = channels[i]; + final country = countries[i]; + final flag = showFlags ? flagOf(country.code) : ''; return Material( color: AppColors.card, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(20), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: () => onPlay(channel), + onTap: () => onOpen(country.code), child: Container( - width: 148, - padding: const EdgeInsets.symmetric(horizontal: 10), + padding: const EdgeInsets.symmetric(horizontal: 13), + alignment: Alignment.center, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.06), + ), ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 34, - height: 34, - child: channel.logoUrl == null - ? Icon( - Icons.live_tv_rounded, - size: 18, - color: AppColors.textHint, - ) - : CachedNetworkImage( - imageUrl: channel.logoUrl!, - fit: BoxFit.contain, - errorWidget: (_, _, _) => Icon( - Icons.live_tv_rounded, - size: 18, - color: AppColors.textHint, - ), - ), + if (flag.isNotEmpty) ...[ + Text(flag, style: const TextStyle(fontSize: 15)), + const SizedBox(width: 7), + ], + Text( + nameOf(country.code), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), ), - const SizedBox(width: 9), - Expanded( - child: Text( - channel.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 11.5, - height: 1.15, - fontWeight: FontWeight.w600, - ), + const SizedBox(width: 6), + Text( + '${country.count}', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + fontWeight: FontWeight.w700, ), ), ], @@ -700,62 +1184,39 @@ class _RecentRow extends StatelessWidget { } } -class _Header extends StatelessWidget { - const _Header({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(18, 14, 18, 8), - child: Text( - label, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w800, - letterSpacing: 0.8, - color: AppColors.textHint, - ), - ), - ), - ); - } -} - - -class _Grid extends StatelessWidget { - const _Grid({ +/// The channels themselves. +class _ChannelGrid extends StatelessWidget { + const _ChannelGrid({ required this.channels, required this.favourites, + required this.width, + required this.now, required this.onPlay, - required this.onFavourite, + required this.onMore, }); final List<LiveChannel> channels; final Set<String> favourites; + final double width; + final DateTime now; final ValueChanged<LiveChannel> onPlay; - final ValueChanged<LiveChannel> onFavourite; + final ValueChanged<LiveChannel> onMore; @override Widget build(BuildContext context) { // Channel logos are wide, not poster-shaped, so the cell is landscape and // the count follows the width rather than a fixed number. - const gutter = 14.0; - const spacing = 10.0; - final width = MediaQuery.sizeOf(context).width; - final columns = width >= 900 ? 5 : (width >= 620 ? 4 : 3); - final cell = (width - gutter * 2 - spacing * (columns - 1)) / columns; + final columns = _columnsFor(width); + final cell = (width - _kGutter * 2 - _kSpacing * (columns - 1)) / columns; final caption = _ChannelCard.reserveCaption(context); return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: gutter), + padding: const EdgeInsets.symmetric(horizontal: _kGutter), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, + mainAxisSpacing: _kSpacing, + crossAxisSpacing: _kSpacing, // The logo box is what the cell is for, so the caption's two lines // are added to it rather than taken out of it — a fixed ratio let a // long name eat the logo, and a one-line name grow it. @@ -766,8 +1227,10 @@ class _Grid extends StatelessWidget { channel: channels[i], favourite: favourites.contains(channels[i].id), captionHeight: caption, + cell: cell, + now: now, onPlay: () => onPlay(channels[i]), - onFavourite: () => onFavourite(channels[i]), + onMore: () => onMore(channels[i]), ), childCount: channels.length, ), @@ -781,12 +1244,15 @@ class _ChannelCard extends StatelessWidget { required this.channel, required this.favourite, required this.captionHeight, + required this.cell, + required this.now, required this.onPlay, - required this.onFavourite, + required this.onMore, }); static const double _fontSize = 11.5; static const double _lineHeight = 1.2; + static const double _slotFontSize = 10.5; /// Two lines, always. Channel names run from "TV1" to "Discovery Science HD", /// and letting the caption size itself left every logo in a row at a @@ -797,84 +1263,142 @@ class _ChannelCard extends StatelessWidget { final LiveChannel channel; final bool favourite; final double captionHeight; + final double cell; + final DateTime now; final VoidCallback onPlay; - final VoidCallback onFavourite; + final VoidCallback onMore; @override Widget build(BuildContext context) { - return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(14), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onPlay, - onLongPress: onFavourite, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: favourite - ? AppColors.primary.withValues(alpha: 0.45) - : Colors.white.withValues(alpha: 0.06), + final slot = channel.slotAt(now); + final bar = _barFactor(slot, now); + final dpr = MediaQuery.devicePixelRatioOf(context); + final logoWidth = cell - 24; + + return Semantics( + container: true, + button: true, + label: slot == null ? channel.name : '${channel.name}. ${slot.title}', + child: Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPlay, + onLongPress: onMore, + onSecondaryTap: onMore, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: favourite + ? AppColors.primary.withValues(alpha: 0.45) + : Colors.white.withValues(alpha: 0.06), + ), ), - ), - child: Column( - children: [ - Expanded( - child: Stack( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.all(12), - child: channel.logoUrl == null - ? _Fallback(name: channel.name) - : CachedNetworkImage( - imageUrl: channel.logoUrl!, - fit: BoxFit.contain, - // Logos come from wherever the playlist points, - // and a dead one is common — the initial reads - // better than a broken-image glyph. - errorWidget: (_, _, _) => - _Fallback(name: channel.name), - placeholder: (_, _) => - _Fallback(name: channel.name), - ), - ), - ), - if (favourite) - const Positioned( - top: 6, - right: 6, - child: Icon( - Icons.star_rounded, - size: 15, - color: AppColors.primary, + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(12), + child: channel.logoUrl == null + ? _Fallback(name: channel.name) + : CachedNetworkImage( + imageUrl: channel.logoUrl!, + fit: BoxFit.contain, + memCacheWidth: logoWidth > 0 + ? (logoWidth * dpr).round() + : null, + // Logos come from wherever the playlist + // points, and a dead one is common — the + // initial reads better than a broken-image + // glyph. + errorWidget: (_, _, _) => + _Fallback(name: channel.name), + placeholder: (_, _) => + const SizedBox.shrink(), + ), ), ), - ], + if (favourite) + Positioned( + top: 6, + right: 6, + child: Icon( + Icons.star_rounded, + size: 15, + color: AppColors.primary, + ), + ), + // Rides the bottom edge of the logo box, so it costs the + // cell no height at all. + if (bar != null) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SizedBox( + height: 2, + child: Container( + color: Colors.white.withValues(alpha: 0.08), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: bar, + heightFactor: 1, + child: Container(color: AppColors.primary), + ), + ), + ), + ), + ], + ), ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 9), - child: SizedBox( - height: captionHeight, - child: Center( - child: Text( - channel.name, - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: _fontSize, - height: _lineHeight, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 9), + child: SizedBox( + height: captionHeight, + // One name line plus a slot line at 10.5 is strictly + // shorter than the two name lines the box reserves, so a + // guide costs the grid nothing and a channel without one + // simply keeps both lines for its name. + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + channel.name, + maxLines: slot == null ? 2 : 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: _fontSize, + height: _lineHeight, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + if (slot != null) + Text( + slot.title, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: _slotFontSize, + height: _lineHeight, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], ), ), ), - ), - ], + ], + ), ), ), ), @@ -902,6 +1426,123 @@ class _Fallback extends StatelessWidget { } } +class _LoadMoreFooter extends StatelessWidget { + const _LoadMoreFooter(); + + @override + Widget build(BuildContext context) { + return const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 18), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2.2), + ), + ), + ), + ); + } +} + +/// The channel grid's shape, before it has anything in it. +/// +/// The same delegate and the same aspect ratio as the real grid, so the +/// channels land exactly where the skeleton sat. +class _GridSkeleton extends StatelessWidget { + const _GridSkeleton({required this.width, required this.count}); + + final double width; + final int count; + + @override + Widget build(BuildContext context) { + final columns = _columnsFor(width); + final cell = (width - _kGutter * 2 - _kSpacing * (columns - 1)) / columns; + final caption = _ChannelCard.reserveCaption(context); + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: _kSpacing, + crossAxisSpacing: _kSpacing, + childAspectRatio: cell / (cell * 0.73 + caption + 9), + ), + delegate: SliverChildBuilderDelegate( + (_, _) => const ShimmerWrapper( + child: HomeSkeletonBox( + width: double.infinity, + height: double.infinity, + radius: 14, + ), + ), + childCount: count, + ), + ), + ); + } +} + +class _CategorySkeleton extends StatelessWidget { + const _CategorySkeleton({required this.width}); + + final double width; + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _CategoryGrid.columnsFor(width), + mainAxisSpacing: _kSpacing, + crossAxisSpacing: _kSpacing, + mainAxisExtent: _CategoryGrid.extentFor(context), + ), + delegate: SliverChildBuilderDelegate( + (_, _) => const ShimmerWrapper( + child: HomeSkeletonBox( + width: double.infinity, + height: double.infinity, + radius: 14, + ), + ), + childCount: 6, + ), + ), + ); + } +} + +class _CountryRailSkeleton extends StatelessWidget { + const _CountryRailSkeleton(); + + /// Uneven on purpose: four identical pills read as a rendering artefact + /// rather than as country names on their way. + static const List<double> _widths = [96, 78, 110, 86]; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: SizedBox( + height: 40, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + itemCount: _widths.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) => ShimmerWrapper( + child: HomeSkeletonBox(width: _widths[i], height: 40, radius: 20), + ), + ), + ), + ); + } +} + class _Empty extends StatelessWidget { const _Empty({ required this.icon, @@ -923,13 +1564,17 @@ class _Empty extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 46, color: AppColors.textHint.withValues(alpha: 0.6)), + Icon( + icon, + size: 46, + color: AppColors.textHint.withValues(alpha: 0.6), + ), const SizedBox(height: 14), Text( text, textAlign: TextAlign.center, style: const TextStyle( - color: AppColors.textHint, + color: AppColors.textSecondary, fontSize: 13.5, height: 1.5, ), diff --git a/lib/features/live_tv/presentation/widgets/channel_sheet.dart b/lib/features/live_tv/presentation/widgets/channel_sheet.dart new file mode 100644 index 00000000..918393ee --- /dev/null +++ b/lib/features/live_tv/presentation/widgets/channel_sheet.dart @@ -0,0 +1,490 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; +import 'package:soplay/features/live_tv/data/live_tv_service.dart'; + +/// One channel, opened from a long press. +/// +/// The card can only ever carry a name and a line of guide; this is where the +/// rest of it lives — what is on, what follows, the day ahead, and the two +/// things anybody wants to do with a channel. +Future<void> showChannelSheet({ + required BuildContext context, + required LiveChannel channel, + required bool favourite, + required VoidCallback onPlay, + required VoidCallback onToggleFavourite, +}) { + return showAdaptiveModal<void>( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: AppColors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + builder: (_) => ChannelSheet( + channel: channel, + favourite: favourite, + onPlay: onPlay, + onToggleFavourite: onToggleFavourite, + ), + ); +} + +class ChannelSheet extends StatefulWidget { + const ChannelSheet({ + super.key, + required this.channel, + required this.favourite, + required this.onPlay, + required this.onToggleFavourite, + }); + + final LiveChannel channel; + final bool favourite; + final VoidCallback onPlay; + final VoidCallback onToggleFavourite; + + @override + State<ChannelSheet> createState() => _ChannelSheetState(); +} + +class _ChannelSheetState extends State<ChannelSheet> { + late bool _favourite; + LiveSchedule? _schedule; + bool _loadingSchedule = true; + + @override + void initState() { + super.initState(); + _favourite = widget.favourite; + _loadSchedule(); + } + + /// Read once, here — a future created in `build()` is refetched every time + /// the favourite button rebuilds this widget. + Future<void> _loadSchedule() async { + try { + final schedule = await getIt<LiveTvService>().schedule( + widget.channel.id, + hours: 24, + ); + if (!mounted) return; + setState(() { + _schedule = schedule; + _loadingSchedule = false; + }); + } catch (_) { + if (!mounted) return; + // A missing guide is not worth saying out loud twice; the empty branch + // below already says it once, in the one place the user asked for it. + setState(() => _loadingSchedule = false); + } + } + + void _play() { + Navigator.of(context).pop(); + widget.onPlay(); + } + + void _toggleFavourite() { + setState(() => _favourite = !_favourite); + // The sheet stays open: favouriting is reversible and the guide underneath + // is what the user came for. + widget.onToggleFavourite(); + } + + @override + Widget build(BuildContext context) { + final channel = widget.channel; + final at = DateTime.now(); + final slot = channel.slotAt(at); + final bar = _barValue(slot, at); + final next = channel.next; + + return ConstrainedBox( + // Both of showAdaptiveModal's dialog paths hand down an unbounded height + // through their own scroll view; this is what makes it finite. + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.78, + ), + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.fromLTRB( + 18, + 6, + 18, + MediaQuery.paddingOf(context).bottom + 14, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _identity(context, channel), + if (slot != null) ..._nowBlock(slot, bar), + if (next != null) ..._nextBlock(next), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: _play, + icon: const Icon(Icons.play_arrow_rounded, size: 20), + label: Text('live_tv.watch'.tr()), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: _toggleFavourite, + icon: Icon( + _favourite ? Icons.star_rounded : Icons.star_border_rounded, + size: 20, + color: _favourite + ? AppColors.primary + : AppColors.textSecondary, + ), + label: Text( + _favourite + ? 'live_tv.favourite_remove'.tr() + : 'live_tv.favourite_add'.tr(), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + const Icon( + Icons.list_alt_rounded, + size: 18, + color: AppColors.textSecondary, + ), + const SizedBox(width: 8), + Text( + 'live_tv.guide'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + const SizedBox(height: 10), + _guide(at), + ], + ), + ), + ), + ); + } + + Widget _identity(BuildContext context, LiveChannel channel) { + final dpr = MediaQuery.devicePixelRatioOf(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 48, + height: 48, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: channel.logoUrl == null + ? const Icon( + Icons.live_tv_rounded, + size: 22, + color: AppColors.textHint, + ) + : CachedNetworkImage( + imageUrl: channel.logoUrl!, + fit: BoxFit.contain, + memCacheWidth: (32 * dpr).round(), + errorWidget: (_, _, _) => const Icon( + Icons.live_tv_rounded, + size: 22, + color: AppColors.textHint, + ), + placeholder: (_, _) => const SizedBox.shrink(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + channel.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15.5, + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + if (channel.category.isNotEmpty) ...[ + const SizedBox(height: 3), + // English, exactly as the line-up delivers it — the channel + // names beside it are English too. + Text( + channel.category, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ], + ); + } + + List<Widget> _nowBlock(LiveProgramme slot, double? bar) { + final detail = [ + slot.episodeLabel, + slot.subtitle, + ].where((value) => value.isNotEmpty).join(' · '); + + return [ + const SizedBox(height: 18), + Text( + 'live_tv.now'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 5), + Text( + slot.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + height: 1.25, + ), + ), + if (detail.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w500, + ), + ), + ], + if (slot.hasWindow) ...[ + const SizedBox(height: 8), + Text( + '${_hhmm(slot.start!)} – ${_hhmm(slot.stop!)}', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + ], + if (bar != null) ...[ + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: SizedBox( + // Explicitly full width: a column lays its children out loose, and + // a track measured from its own fill is no track at all. + width: double.infinity, + height: 3, + child: Container( + color: AppColors.surfaceVariant, + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: bar, + heightFactor: 1, + child: Container(color: AppColors.primary), + ), + ), + ), + ), + ], + if (slot.description.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + slot.description, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + height: 1.45, + ), + ), + ], + ]; + } + + List<Widget> _nextBlock(LiveProgramme next) { + return [ + const SizedBox(height: 16), + Text( + 'live_tv.next'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 5), + Row( + children: [ + if (next.hasWindow) ...[ + Text( + _hhmm(next.start!), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 9), + ], + Expanded( + child: Text( + next.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ]; + } + + Widget _guide(DateTime at) { + if (_loadingSchedule) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: List<Widget>.generate( + 3, + (_) => const Padding( + padding: EdgeInsets.symmetric(vertical: 9), + child: ShimmerWrapper( + child: Row( + children: [ + HomeSkeletonBox(width: 46, height: 10), + SizedBox(width: 10), + HomeSkeletonBox(width: 160, height: 10), + ], + ), + ), + ), + ), + ); + } + + final schedule = _schedule; + // The live row is already the NOW block above, and a row with no time has + // nothing to put in its left column. + final rows = schedule == null + ? const <LiveProgramme>[] + : schedule + .from(at) + .where((p) => p.start != null && !p.isLiveAt(at)) + .take(8) + .toList(growable: false); + + if (rows.isEmpty) { + return Text( + 'live_tv.no_guide'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + fontWeight: FontWeight.w500, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < rows.length; i++) ...[ + if (i > 0) Divider(height: 1, thickness: 1, color: AppColors.divider), + Padding( + padding: const EdgeInsets.symmetric(vertical: 9), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 46, + child: Text( + _hhmm(rows[i].start!), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + rows[i].title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ], + ); + } +} + +/// Width factor for the NOW progress bar, or null when there is nothing honest +/// to draw. The sheet is open for seconds, so this is read once per build and +/// never ticked. +double? _barValue(LiveProgramme? slot, DateTime at) { + if (slot == null || !slot.isBarWorthy) return null; + final value = slot.progressAt(at); + if (value <= 0) return null; + return value < 0.02 ? 0.02 : value; +} + +/// 24-hour, hand-formatted. +/// +/// `intl` is not a declared dependency — it reaches the app only through +/// easy_localization's re-export — and `DateFormat.Hm` throws without locale +/// data initialised for uz. Digits need no translation either way. +String _hhmm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index b2e021fd..8ba877d5 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -25,6 +25,13 @@ import 'package:showcaseview/showcaseview.dart'; import '../../../../core/navigation/app_tab.dart'; import '../../../../core/navigation/nav_controller.dart'; +/// Addressed by name, never through `ShowcaseView.get()`: that returns the most recently +/// registered instance, so opening a detail page — which registers its own private scope — +/// silently rebound this page's showcases to it. When the detail page went away it took the +/// scope with it and the nav capsule threw on its next rebuild. +const String _showcaseScope = 'main-nav'; + + class MainPage extends StatefulWidget { const MainPage({super.key}); @@ -84,6 +91,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver { _navController.index.addListener(_onNavChange); WidgetsBinding.instance.addObserver(this); ShowcaseView.register( + scope: _showcaseScope, blurValue: 1.5, overlayColor: Colors.black, overlayOpacity: 0.76, @@ -136,7 +144,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver { WidgetsBinding.instance.removeObserver(this); _navController.index.removeListener(_onNavChange); NavPrefs.tabOrder.removeListener(_onTabSetChange); - ShowcaseView.get().unregister(); + ShowcaseView.getNamed(_showcaseScope).unregister(); _tvRailScope.dispose(); for (final n in _tvTabScopes.values) { n.dispose(); @@ -205,7 +213,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver { _shortsShowcaseStarted = false; return; } - ShowcaseView.get().startShowCase([_shortsRefreshShowcaseKey]); + ShowcaseView.getNamed(_showcaseScope).startShowCase([_shortsRefreshShowcaseKey]); }); }); } @@ -463,7 +471,10 @@ class _SoplayGlassCapsule extends StatelessWidget { // The rim is deliberately quiet: a bright specular edge on a dark capsule // reads as a drawn outline rather than as glass, so the highlight, the // fresnel glow and the refraction band are all kept thin. - static const _glassSettings = LiquidGlassSettings( + // Not const: `backerColor` is the page background, and that is a runtime + // value now — on AMOLED the pad has to go to true black with the rest of the + // app instead of staying a #1A1A1A slab floating on nothing. + static LiquidGlassSettings get _glassSettings => LiquidGlassSettings( thickness: 14, // narrower edge band → a finer rim, not a fat outline blur: 5, // less haze behind the bar → cleaner, not muddy chromaticAberration: 0.08, // barely-there edge tint @@ -472,14 +483,16 @@ class _SoplayGlassCapsule extends StatelessWidget { lightIntensity: 0.6, // soft top-edge sheen instead of a hard stroke ambientStrength: 1, glowIntensity: 0.25, // faint luminous rim - glassColor: Color(0x12FFFFFF), // faint white sheen - backerColor: Color(0xE61A1A1A), // near-solid dark pad → premium, not hazy + glassColor: const Color(0x12FFFFFF), // faint white sheen + // Near-solid pad → premium, not hazy. #181818 at 90% on the default theme, + // which is where the old #1A1A1A literal sat. + backerColor: AppColors.background.withValues(alpha: 0.902), whitenStrength: 0.0, ); // Solid (glass off): a near-opaque dark pad, no blur/refraction — the cheap, // always-smooth path. - static const _solidSettings = LiquidGlassSettings( + static LiquidGlassSettings get _solidSettings => LiquidGlassSettings( thickness: 0, blur: 0, chromaticAberration: 0, @@ -487,8 +500,8 @@ class _SoplayGlassCapsule extends StatelessWidget { saturation: 1, lightIntensity: 0, ambientStrength: 0, - glassColor: Color(0x00000000), - backerColor: Color(0xF01A1A1A), // ~94% solid dark + glassColor: const Color(0x00000000), + backerColor: AppColors.background.withValues(alpha: 0.941), // ~94% solid whitenStrength: 0.0, ); @@ -514,19 +527,33 @@ class _SoplayGlassCapsule extends StatelessWidget { barBorderRadius: _barHeight / 2, // full capsule magnification: glass ? 1.12 : 1.0, // subtle iOS-26 lens on the selected tab indicatorPinchStrength: glass ? 0.3 : 0.0, - // Selected-tab pill: a soft, restrained light pill on the dark body. - indicatorColor: Color(glass ? 0x22FFFFFF : 0x18FFFFFF), + // Selected-tab pill: a soft, restrained light pill on the dark body — + // unless Appearance → "Colour the tab bar" is on, in which case the whole + // selected state moves onto the accent. Off by default: the white pill is + // a deliberate part of the shipped design, so it changes only when asked. + indicatorColor: AppColors.isNavTinted + ? AppColors.primary.withValues(alpha: glass ? 0.26 : 0.20) + : Color(glass ? 0x22FFFFFF : 0x18FFFFFF), quality: glass ? null : GlassQuality.minimal, settings: glass ? _glassSettings : _solidSettings, - selectedIconColor: Colors.white, + // primaryLight, not primary: a selected icon is a small glyph on a dark + // bar, and the lighter variant is the one that reads at that size. + selectedIconColor: + AppColors.isNavTinted ? AppColors.primaryLight : Colors.white, unselectedIconColor: const Color(0xFF7A7A7A), - selectedLabelColor: Colors.white, + selectedLabelColor: + AppColors.isNavTinted ? AppColors.primaryLight : Colors.white, unselectedLabelColor: const Color(0xFF7A7A7A), labelFontSize: 10.5, ); // The package drop shadow is light-mode only, so paint our own soft capsule // shadow behind the glass for the "floating" look on the dark UI. + // + // A black shadow only separates the bar from a background that is lighter + // than black. Under AMOLED both are #000000 and the capsule dissolves into + // the page, so there it swaps to a faint light halo — the only direction a + // true-black page leaves to work in. final shadowed = Stack( children: [ Positioned.fill( @@ -534,13 +561,19 @@ class _SoplayGlassCapsule extends StatelessWidget { child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(_barHeight / 2), - boxShadow: const [ - BoxShadow( - color: Color(0x66000000), - blurRadius: 24, - spreadRadius: -4, - offset: Offset(0, 10), - ), + boxShadow: [ + AppColors.isBlack + ? BoxShadow( + color: Colors.white.withValues(alpha: 0.10), + blurRadius: 18, + spreadRadius: -6, + ) + : const BoxShadow( + color: Color(0x66000000), + blurRadius: 24, + spreadRadius: -4, + offset: Offset(0, 10), + ), ], ), ), @@ -598,9 +631,10 @@ class _SoplayClassicBar extends StatelessWidget { child: Container( height: 68 + bottomPad, decoration: BoxDecoration( - // A translucent frosted grey (not near-black) so content shows - // through the blur and the bar reads as frosted glass. - color: const Color(0xFF262626).withValues(alpha: 0.72), + // A translucent frosted surface (not near-black) so content shows + // through the blur and the bar reads as frosted glass. Follows the + // palette, so AMOLED gets a genuinely black frosted bar. + color: AppColors.surface.withValues(alpha: 0.72), borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), border: Border( top: BorderSide( @@ -668,7 +702,9 @@ class _ClassicNavButtonState extends State<_ClassicNavButton> { @override Widget build(BuildContext context) { - final color = widget.selected ? Colors.white : const Color(0xFF7A7A7A); + final selectedColor = + AppColors.isNavTinted ? AppColors.primaryLight : Colors.white; + final color = widget.selected ? selectedColor : const Color(0xFF7A7A7A); final button = Semantics( button: true, @@ -708,7 +744,7 @@ class _ClassicNavButtonState extends State<_ClassicNavButton> { shadows: widget.selected ? [ Shadow( - color: Colors.white.withValues(alpha: 0.28), + color: selectedColor.withValues(alpha: 0.28), blurRadius: 10, ), ] @@ -793,7 +829,7 @@ class _SoplayFloatingNav extends StatelessWidget { child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: const Color(0xFF262626).withValues(alpha: 0.72), + color: AppColors.surface.withValues(alpha: 0.72), borderRadius: radius, border: Border.all( color: Colors.white.withValues(alpha: 0.10), @@ -937,7 +973,7 @@ class _TvNavRailState extends State<_TvNavRail> { : _TvNavRail.collapsedWidth, decoration: BoxDecoration( color: AppColors.navBackground, - border: const Border( + border: Border( right: BorderSide(color: AppColors.border, width: 0.6), ), boxShadow: _expanded @@ -1088,7 +1124,7 @@ class _ShortsRefreshShowcaseCard extends StatelessWidget { width: 276, padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: const Color(0xFF121212).withValues(alpha: 0.96), + color: AppColors.navBackground.withValues(alpha: 0.96), borderRadius: BorderRadius.circular(18), border: Border.all(color: Colors.white.withValues(alpha: 0.10)), boxShadow: [ @@ -1114,14 +1150,14 @@ class _ShortsRefreshShowcaseCard extends StatelessWidget { height: 34, decoration: BoxDecoration( shape: BoxShape.circle, - gradient: const LinearGradient( - colors: [Color(0xFFE53935), Color(0xFFB71C1C)], + gradient: LinearGradient( + colors: [AppColors.primary, AppColors.primaryDark], begin: Alignment.topLeft, end: Alignment.bottomRight, ), boxShadow: [ BoxShadow( - color: const Color(0xFFE53935).withValues(alpha: 0.35), + color: AppColors.primary.withValues(alpha: 0.35), blurRadius: 14, ), ], @@ -1176,7 +1212,7 @@ class _ShortsRefreshShowcaseCard extends StatelessWidget { ), const Spacer(), GestureDetector( - onTap: () => ShowcaseView.get().dismiss(), + onTap: () => ShowcaseView.getNamed(_showcaseScope).dismiss(), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, diff --git a/lib/features/mal/presentation/widgets/mal_entry_sheet.dart b/lib/features/mal/presentation/widgets/mal_entry_sheet.dart index 2e242bcb..431745d5 100644 --- a/lib/features/mal/presentation/widgets/mal_entry_sheet.dart +++ b/lib/features/mal/presentation/widgets/mal_entry_sheet.dart @@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; import 'package:soplay/features/mal/domain/entities/mal_entities.dart'; import 'package:soplay/features/mal/presentation/controllers/mal_library_controller.dart'; @@ -124,7 +125,7 @@ class _MalEntrySheetState extends State<MalEntrySheet> { return SafeArea( top: false, child: Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -256,10 +257,10 @@ class _MalEntrySheetState extends State<MalEntrySheet> { onPressed: busy ? null : () => _confirmRemove(entry), style: OutlinedButton.styleFrom( foregroundColor: AppColors.error, - side: const BorderSide(color: AppColors.border), + side: BorderSide(color: AppColors.border), padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.delete_outline_rounded, size: 18), diff --git a/lib/features/mal/presentation/widgets/mal_link_sheet.dart b/lib/features/mal/presentation/widgets/mal_link_sheet.dart index b0eabfe5..475e47e1 100644 --- a/lib/features/mal/presentation/widgets/mal_link_sheet.dart +++ b/lib/features/mal/presentation/widgets/mal_link_sheet.dart @@ -169,7 +169,7 @@ class _MalLinkSheetState extends State<MalLinkSheet> { maxChildSize: 0.95, expand: false, builder: (context, scrollController) => Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/features/manga/presentation/pages/manga_source_settings_page.dart b/lib/features/manga/presentation/pages/manga_source_settings_page.dart index df201812..f450100f 100644 --- a/lib/features/manga/presentation/pages/manga_source_settings_page.dart +++ b/lib/features/manga/presentation/pages/manga_source_settings_page.dart @@ -19,7 +19,9 @@ class MangaSourceSettingsPage extends StatefulWidget { } class _MangaSourceSettingsPageState extends State<MangaSourceSettingsPage> { - static const Color _accent = Color(0xFF5B8DEF); + /// The manga screens used to carry their own private blue. There is one + /// accent in the app now, and the user chooses it. + static Color get _accent => AppColors.primary; List<Map<String, dynamic>> _prefs = const []; bool _loading = true; @@ -58,7 +60,7 @@ class _MangaSourceSettingsPageState extends State<MangaSourceSettingsPage> { title: Text(widget.name), ), body: _loading - ? const Center( + ? Center( child: CircularProgressIndicator(color: _accent, strokeWidth: 2)) : _prefs.isEmpty ? Center( @@ -193,7 +195,7 @@ class _MangaSourceSettingsPageState extends State<MangaSourceSettingsPage> { style: const TextStyle(color: Colors.white, fontSize: 14)), trailing: isSelected - ? const Icon(Icons.check, color: _accent, size: 20) + ? Icon(Icons.check, color: _accent, size: 20) : null, onTap: () => Navigator.of(context).pop(value), ); @@ -276,7 +278,7 @@ class _MangaSourceSettingsPageState extends State<MangaSourceSettingsPage> { controller: controller, autofocus: true, style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( + decoration: InputDecoration( hintStyle: TextStyle(color: AppColors.textHint), enabledBorder: UnderlineInputBorder( borderSide: BorderSide(color: Colors.white24)), diff --git a/lib/features/manga/presentation/pages/manga_sources_page.dart b/lib/features/manga/presentation/pages/manga_sources_page.dart index cb3a083e..4ecd7d06 100644 --- a/lib/features/manga/presentation/pages/manga_sources_page.dart +++ b/lib/features/manga/presentation/pages/manga_sources_page.dart @@ -24,7 +24,9 @@ class MangaSourcesPage extends StatefulWidget { class _MangaSourcesPageState extends State<MangaSourcesPage> { static const String _logo = 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcShNP_m0078YcYRUbudCuZhohC2U143Re4MfQ&s'; - static const Color _accent = Color(0xFF5B8DEF); + /// The manga screens used to carry their own private blue. There is one + /// accent in the app now, and the user chooses it. + static Color get _accent => AppColors.primary; late final HiveService _hive = getIt<HiveService>(); @@ -264,7 +266,7 @@ class _MangaSourcesPageState extends State<MangaSourcesPage> { const SizedBox(height: 24), Row( children: [ - const Icon(Icons.tune, size: 15, color: _accent), + Icon(Icons.tune, size: 15, color: _accent), const SizedBox(width: 6), Text('manga.source_settings'.tr(), style: Theme.of(context).textTheme.labelSmall?.copyWith( @@ -415,7 +417,7 @@ class _MangaSourcesPageState extends State<MangaSourcesPage> { decoration: InputDecoration( labelText: 'manga.repo_url'.tr(), labelStyle: const TextStyle(color: AppColors.textHint), - floatingLabelStyle: const TextStyle(color: _accent), + floatingLabelStyle: TextStyle(color: _accent), hintText: 'https://…/index.min.json', hintStyle: const TextStyle(color: AppColors.textHint), filled: true, diff --git a/lib/features/manga/presentation/pages/reader_page.dart b/lib/features/manga/presentation/pages/reader_page.dart index 15e7283b..4804110d 100644 --- a/lib/features/manga/presentation/pages/reader_page.dart +++ b/lib/features/manga/presentation/pages/reader_page.dart @@ -23,6 +23,7 @@ import 'package:soplay/features/history/data/history_service.dart'; import 'package:soplay/features/history/domain/entities/history_item.dart'; import 'package:soplay/features/manga/domain/entities/manga_page_entity.dart'; import 'package:soplay/features/manga/domain/entities/reader_args.dart'; +import 'package:soplay/core/theme/app_colors.dart'; class ReaderPage extends StatefulWidget { final ReaderArgs args; @@ -33,7 +34,9 @@ class ReaderPage extends StatefulWidget { } class _ReaderPageState extends State<ReaderPage> { - static const Color _accent = Color(0xFF5B8DEF); + /// The manga screens used to carry their own private blue. There is one + /// accent in the app now, and the user chooses it. + static Color get _accent => AppColors.primary; final _hive = getIt<HiveService>(); final _downloads = getIt<DownloadService>(); @@ -400,7 +403,7 @@ class _ReaderPageState extends State<ReaderPage> { Widget _content() { if (_loading) { - return const Center( + return Center( child: CircularProgressIndicator(color: _accent, strokeWidth: 2), ); } @@ -1033,7 +1036,9 @@ class _PageImage extends StatefulWidget { } class _PageImageState extends State<_PageImage> { - static const Color _accent = Color(0xFF5B8DEF); + /// The manga screens used to carry their own private blue. There is one + /// accent in the app now, and the user chooses it. + static Color get _accent => AppColors.primary; int _retry = 0; /// Chapter headers plus this page's host-scoped cookies, if it has any. @@ -1077,7 +1082,7 @@ class _PageImageState extends State<_PageImage> { width: width, height: width * 1.4, color: Colors.white.withValues(alpha: 0.02), - child: const Center( + child: Center( child: CircularProgressIndicator(color: _accent, strokeWidth: 1.8), ), diff --git a/lib/features/my_list/presentation/widgets/my_list_background.dart b/lib/features/my_list/presentation/widgets/my_list_background.dart index 4c65367c..c36d583f 100644 --- a/lib/features/my_list/presentation/widgets/my_list_background.dart +++ b/lib/features/my_list/presentation/widgets/my_list_background.dart @@ -11,7 +11,15 @@ class MyListBackground extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [const Color(0xFF161616), AppColors.background, const Color(0xFF101010)], + // The old literals were #161616 and #101010 — a shade under the + // background and the same fall-off Profile's backdrop uses. Derived + // now, so the page goes true black with everything else instead of + // staying a grey slab. + colors: [ + Color.lerp(AppColors.background, Colors.black, 0.083)!, + AppColors.background, + AppColors.heroBottom, + ], stops: const [0, 0.42, 1], ), ), diff --git a/lib/features/network/presentation/pages/no_internet_page.dart b/lib/features/network/presentation/pages/no_internet_page.dart index c7476e9d..23447eaa 100644 --- a/lib/features/network/presentation/pages/no_internet_page.dart +++ b/lib/features/network/presentation/pages/no_internet_page.dart @@ -65,7 +65,7 @@ class _NoInternetPageState extends State<NoInternetPage> { color: AppColors.surface, borderRadius: BorderRadius.circular(8), ), - child: const Icon( + child: Icon( Icons.wifi_off_rounded, color: AppColors.primary, size: 26, @@ -117,7 +117,7 @@ class _NoInternetPageState extends State<NoInternetPage> { label: Text('navigation.downloads'.tr()), style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.divider), + side: BorderSide(color: AppColors.divider), ), ), ), diff --git a/lib/features/notifications/presentation/pages/notifications_page.dart b/lib/features/notifications/presentation/pages/notifications_page.dart index fcee0bee..c930d0a9 100644 --- a/lib/features/notifications/presentation/pages/notifications_page.dart +++ b/lib/features/notifications/presentation/pages/notifications_page.dart @@ -97,7 +97,7 @@ class _NotificationsViewState extends State<_NotificationsView> { .add(const NotificationsMarkAllRead()), child: Text( 'notifications.mark_all_read'.tr(), - style: const TextStyle(color: AppColors.primary), + style: TextStyle(color: AppColors.primary), ), ); }, @@ -107,7 +107,7 @@ class _NotificationsViewState extends State<_NotificationsView> { body: BlocBuilder<NotificationsBloc, NotificationsState>( builder: (context, state) { if (state.loading && state.items.isEmpty) { - return const Center( + return Center( child: CircularProgressIndicator(color: AppColors.primary), ); } @@ -142,7 +142,7 @@ class _NotificationsViewState extends State<_NotificationsView> { separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { if (index >= state.items.length) { - return const Padding( + return Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: CircularProgressIndicator(color: AppColors.primary), @@ -340,10 +340,10 @@ class _NotificationTile extends StatelessWidget { CachedNetworkImage( imageUrl: item.imageUrl!, fit: BoxFit.cover, - placeholder: (_, _) => const ColoredBox( + placeholder: (_, _) => ColoredBox( color: AppColors.surfaceVariant, ), - errorWidget: (_, _, _) => const ColoredBox( + errorWidget: (_, _, _) => ColoredBox( color: AppColors.surfaceVariant, ), ), @@ -354,7 +354,7 @@ class _NotificationTile extends StatelessWidget { child: Container( width: 10, height: 10, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -413,7 +413,7 @@ class _NotificationTile extends StatelessWidget { width: 8, height: 8, margin: const EdgeInsets.only(top: 6, right: 10), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -564,7 +564,7 @@ class _ErrorView extends StatelessWidget { onPressed: onRetry, child: Text( 'general.retry'.tr(), - style: const TextStyle(color: AppColors.primary), + style: TextStyle(color: AppColors.primary), ), ), ], diff --git a/lib/features/onboarding/presentation/pages/onboarding_page.dart b/lib/features/onboarding/presentation/pages/onboarding_page.dart index 30b403a1..94103f5f 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_page.dart @@ -81,18 +81,21 @@ class _OnboardingPageState extends State<OnboardingPage> { fit: StackFit.expand, children: [ _Backdrops(controller: _pageController, page: _page), - const DecoratedBox( + DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, + // The last two stops are the page background arriving — the + // third used to be the literal #181818, which under AMOLED + // left the poster fading into grey and then jumping to black. colors: [ - Color(0xB3000000), - Color(0x33000000), - Color(0xF2181818), + const Color(0xB3000000), + const Color(0x33000000), + AppColors.background.withValues(alpha: 0.949), AppColors.background, ], - stops: [0, 0.28, 0.62, 0.8], + stops: const [0, 0.28, 0.62, 0.8], ), ), ), @@ -107,7 +110,7 @@ class _OnboardingPageState extends State<OnboardingPage> { children: [ Text( 'app_name'.tr().toUpperCase(), - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 22, fontWeight: FontWeight.w900, diff --git a/lib/features/onboarding/presentation/widgets/anime_ribbons.dart b/lib/features/onboarding/presentation/widgets/anime_ribbons.dart index 839bc185..7fca60a8 100644 --- a/lib/features/onboarding/presentation/widgets/anime_ribbons.dart +++ b/lib/features/onboarding/presentation/widgets/anime_ribbons.dart @@ -142,7 +142,7 @@ class _Ribbon extends StatelessWidget { errorBuilder: (_, _, _) => SizedBox( width: tileWidth, height: tileHeight, - child: const ColoredBox(color: AppColors.surface), + child: ColoredBox(color: AppColors.surface), ), ), ), diff --git a/lib/features/onboarding/presentation/widgets/tv_showcase.dart b/lib/features/onboarding/presentation/widgets/tv_showcase.dart index c3e45f2d..69a8cfe4 100644 --- a/lib/features/onboarding/presentation/widgets/tv_showcase.dart +++ b/lib/features/onboarding/presentation/widgets/tv_showcase.dart @@ -149,7 +149,7 @@ class _Television extends StatelessWidget { decoration: BoxDecoration( color: const Color(0xFF0C0C0C), borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF3A3A3A), width: 1.4), + border: Border.all(color: AppColors.border, width: 1.4), boxShadow: const [ // Two shadows: one grounds the set, the faint wide one is the // screen bleeding onto the wall behind it. @@ -231,7 +231,7 @@ class _ScreenContent extends StatelessWidget { height: constraints.maxHeight, cacheWidth: width, errorBuilder: (_, _, _) => - const ColoredBox(color: AppColors.surface), + ColoredBox(color: AppColors.surface), ), ), ); @@ -279,7 +279,7 @@ class _LiveBadge extends StatelessWidget { child: Container( width: 6, height: 6, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -311,12 +311,12 @@ class _Stand extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - Container(width: 14, height: 14, color: const Color(0xFF2A2A2A)), + Container(width: 14, height: 14, color: AppColors.divider), Container( width: 86, height: 5, decoration: BoxDecoration( - color: const Color(0xFF3A3A3A), + color: AppColors.border, borderRadius: BorderRadius.circular(3), ), ), diff --git a/lib/features/private_list/presentation/pages/private_list_page.dart b/lib/features/private_list/presentation/pages/private_list_page.dart index f18b0b4b..1fd69706 100644 --- a/lib/features/private_list/presentation/pages/private_list_page.dart +++ b/lib/features/private_list/presentation/pages/private_list_page.dart @@ -43,7 +43,7 @@ class PrivateListPage extends StatelessWidget { shape: BoxShape.circle, color: AppColors.primary.withValues(alpha: 0.12), ), - child: const Icon( + child: Icon( Icons.lock_outline_rounded, color: AppColors.primary, size: 30, diff --git a/lib/features/private_list/presentation/pages/private_unlock_page.dart b/lib/features/private_list/presentation/pages/private_unlock_page.dart index 2b840818..ef796b29 100644 --- a/lib/features/private_list/presentation/pages/private_unlock_page.dart +++ b/lib/features/private_list/presentation/pages/private_unlock_page.dart @@ -88,7 +88,7 @@ class _PrivateUnlockViewState extends State<_PrivateUnlockView> { color: AppColors.primary.withValues(alpha: 0.12), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.lock_rounded, color: AppColors.primary, size: 30, diff --git a/lib/features/profile/presentation/pages/appearance_page.dart b/lib/features/profile/presentation/pages/appearance_page.dart new file mode 100644 index 00000000..a9f6a32a --- /dev/null +++ b/lib/features/profile/presentation/pages/appearance_page.dart @@ -0,0 +1,1146 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/theme/app_accent.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_palette.dart'; +import 'package:soplay/core/theme/theme_controller.dart'; +import 'package:soplay/features/profile/presentation/widgets/library_accents.dart'; +import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; +import 'package:soplay/features/profile/presentation/widgets/theme_preview.dart'; + +/// Settings → Appearance. +/// +/// Two settings, both of which repaint the entire app the instant they are +/// touched: the accent colour and whether the neutrals go to true black. +/// +/// Everything on this page is deliberately shown rather than described. The +/// accent is a row of colours *and* a full miniature of the app; the darkness +/// choice is two miniatures side by side. Nothing here asks the user to imagine +/// what "AMOLED" will do to a screen they are not currently looking at. +class AppearancePage extends StatelessWidget { + const AppearancePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + title: Text( + 'appearance.title'.tr(), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + actions: const [_ResetAction()], + ), + body: const SingleChildScrollView( + padding: EdgeInsets.only(top: 4, bottom: 40), + child: AppearanceSettings(), + ), + ); + } +} + +/// The body of [AppearancePage], on its own so the desktop Profile can drop it +/// straight into its "Appearance" panel instead of keeping a second copy. +class AppearanceSettings extends StatefulWidget { + const AppearanceSettings({super.key, this.showResetRow = false}); + + /// Desktop has no app bar to hang the reset action off, so it gets a row. + final bool showResetRow; + + @override + State<AppearanceSettings> createState() => _AppearanceSettingsState(); +} + +class _AppearanceSettingsState extends State<AppearanceSettings> { + final ThemeController _theme = getIt<ThemeController>(); + + /// Held in the State, not started in build(): this page rebuilds on every + /// colour tap, and a future created in build() would re-decode five posters + /// each time. + final Future<List<AppAccent>> _libraryAccents = LibraryAccents.load(); + + // No addListener / setState pair here on purpose: a theme change already + // marks the whole element tree dirty from MyApp, so this page rebuilds with + // everything else. Listening as well would just rebuild it twice. + + Future<void> _pick(AppAccent accent) async { + _tap(); + await _theme.setAccent(accent); + } + + Future<void> _setAmoled(bool value) async { + _tap(); + await _theme.setAmoled(value); + } + + void _tap() { + if (isMobilePlatform) HapticFeedback.selectionClick(); + } + + Future<void> _setNavTinted(bool value) async { + _tap(); + await _theme.setNavTinted(value); + } + + Future<void> _shuffle() async { + _tap(); + await _theme.cycleAccent(); + } + + Future<void> _openCustom() async { + _tap(); + final picked = await showCustomAccentSheet( + context, + initial: _theme.customSeed ?? _theme.accent.base, + ); + if (picked != null) await _theme.setCustomAccent(picked); + } + + @override + Widget build(BuildContext context) { + final accent = _theme.accent; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Live preview ──────────────────────────────────────────────── + SettingsLabel('appearance.section_preview'.tr()), + const _PreviewStage(), + const SizedBox(height: 20), + + // ── Accent ────────────────────────────────────────────────────── + SettingsLabel('appearance.section_accent'.tr()), + SettingsCard( + children: [ + _AccentGrid(selected: accent, onPick: _pick), + const SettingsDivider(), + _CurrentAccentRow(accent: accent), + const SettingsDivider(), + _CustomAccentRow(active: accent.isCustom, onTap: _openCustom), + ], + ), + SettingsFootnote('appearance.accent_footnote'.tr()), + const SizedBox(height: 20), + + // ── From the user's own library ───────────────────────────────── + _LibraryAccentSection( + accents: _libraryAccents, + selected: accent, + onPick: (a) async { + _tap(); + await _theme.setCustomAccent(a.base); + }, + ), + + // ── Darkness ──────────────────────────────────────────────────── + SettingsLabel('appearance.section_darkness'.tr()), + SettingsCard( + children: [ + _DarknessPicker( + accent: accent, + value: _theme.darkness, + onChanged: (d) => _setAmoled(d == AppDarkness.black), + ), + const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.contrast_rounded, + title: 'appearance.darkness_black'.tr(), + subtitle: 'appearance.darkness_black_desc'.tr(), + value: _theme.isAmoled, + onChanged: _setAmoled, + ), + ], + ), + SettingsFootnote('appearance.amoled_footnote'.tr()), + const SizedBox(height: 20), + + // ── Where the accent is allowed to go ─────────────────────────── + SettingsLabel('appearance.section_extras'.tr()), + SettingsCard( + children: [ + SettingsSwitchTile( + icon: Icons.dashboard_customize_rounded, + title: 'appearance.tint_nav'.tr(), + subtitle: 'appearance.tint_nav_desc'.tr(), + value: _theme.isNavTinted, + onChanged: _setNavTinted, + ), + const SettingsDivider(), + _ActionRow( + icon: Icons.shuffle_rounded, + title: 'appearance.shuffle'.tr(), + subtitle: 'appearance.shuffle_desc'.tr(), + onTap: _shuffle, + ), + ], + ), + + if (widget.showResetRow) ...[ + const SizedBox(height: 20), + SettingsCard( + children: [ + _ResetRow( + enabled: !_theme.isDefault, + onReset: () async { + _tap(); + await _theme.reset(); + }, + ), + ], + ), + ], + ], + ), + ); + } +} + +// ── Preview ───────────────────────────────────────────────────────────────── + +/// Wraps the component sample. +/// +/// No device frame and no stage lighting: the sample is a real card on a real +/// background, and dressing it up as a photographed object would put back the +/// suggestion that it is a picture of a screen somewhere. +class _PreviewStage extends StatelessWidget { + const _PreviewStage(); + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: const ThemePreview(), + ); + } +} + +// ── Accent ────────────────────────────────────────────────────────────────── + +class _AccentGrid extends StatelessWidget { + const _AccentGrid({required this.selected, required this.onPick}); + + final AppAccent selected; + final ValueChanged<AppAccent> onPick; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 16, 14, 16), + child: LayoutBuilder( + builder: (context, constraints) { + // Only the presets live here, and there are twelve of them — which + // divides evenly by 6, 4 and 3. So whatever width the card gets, the + // grid comes out as full rows instead of leaving one swatch stranded + // on a line of its own. (Custom is a labelled row underneath, where + // it can say what it is.) + const gap = 10.0; + final columns = switch (constraints.maxWidth) { + >= 420 => 6, + >= 300 => 6, + >= 220 => 4, + _ => 3, + }; + final size = (constraints.maxWidth - gap * (columns - 1)) / columns; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final accent in AppAccent.presets) + _Swatch( + size: size, + color: accent.base, + selected: !selected.isCustom && selected.id == accent.id, + onTap: () => onPick(accent), + semanticLabel: accent.labelKey.tr(), + ), + ], + ); + }, + ), + ); + } +} + +/// The way into the colour wheel. A row rather than a thirteenth circle: it can +/// carry a label, it never breaks the grid's rhythm, and when a custom colour +/// is in force it shows which one. +class _CustomAccentRow extends StatelessWidget { + const _CustomAccentRow({required this.active, required this.onTap}); + + final bool active; + final VoidCallback onTap; + + /// Hues around the wheel chip. Twelve is enough for the sweep to read as + /// continuous at this size. + static const List<Color> _wheel = [ + Color(0xFFE53935), Color(0xFFF4511E), Color(0xFFFFB300), + Color(0xFFC0CA33), Color(0xFF43A047), Color(0xFF00897B), + Color(0xFF039BE5), Color(0xFF3949AB), Color(0xFF8E24AA), + Color(0xFFD81B60), Color(0xFFE53935), + ]; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const SweepGradient(colors: _wheel), + border: Border.all( + color: active + ? AppColors.textPrimary + : AppColors.textPrimary.withValues(alpha: 0.12), + width: active ? 2 : 1, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'appearance.custom_title'.tr(), + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + if (active) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Icon( + Icons.check_rounded, + size: 18, + color: AppColors.primary, + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + ], + ), + ), + ), + ); + } +} + +class _Swatch extends StatelessWidget { + const _Swatch({ + required this.size, + required this.color, + required this.selected, + required this.onTap, + required this.semanticLabel, + }); + + final double size; + final Color color; + final bool selected; + final VoidCallback onTap; + final String semanticLabel; + + @override + Widget build(BuildContext context) { + return Semantics( + label: semanticLabel, + selected: selected, + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + // The selection ring sits outside the colour, not on it, so a dark + // accent and a bright one are equally obviously selected. + border: Border.all( + color: selected + ? AppColors.textPrimary + : AppColors.textPrimary.withValues(alpha: 0.10), + width: selected ? 2 : 1, + ), + ), + padding: EdgeInsets.all(selected ? 4 : 3), + child: DecoratedBox( + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + child: selected + ? Center( + child: Icon( + Icons.check_rounded, + size: size * 0.42, + color: Colors.white, + ), + ) + : null, + ), + ), + ), + ); + } +} + +/// Names the accent in force and, for a custom one, offers the way back into +/// the picker. Without it the grid is twelve unlabelled dots. +class _CurrentAccentRow extends StatelessWidget { + const _CurrentAccentRow({required this.accent}); + + final AppAccent accent; + + @override + Widget build(BuildContext context) { + final hex = accent.base + .toARGB32() + .toRadixString(16) + .padLeft(8, '0') + .substring(2) + .toUpperCase(); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: accent.base, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.textPrimary.withValues(alpha: 0.12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + accent.labelKey.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + '#$hex', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12.5, + fontFeatures: [FontFeature.tabularFigures()], + letterSpacing: 0.4, + ), + ), + ], + ), + ); + } +} + +/// The accents the user's own library is made of. +/// +/// Hides itself entirely when there is nothing to show — a fresh install, an +/// offline session, posters that failed to decode. An empty labelled card that +/// says "we found nothing" is worse than no card: it turns a bonus into a +/// broken feature. +class _LibraryAccentSection extends StatelessWidget { + const _LibraryAccentSection({ + required this.accents, + required this.selected, + required this.onPick, + }); + + final Future<List<AppAccent>> accents; + final AppAccent selected; + final ValueChanged<AppAccent> onPick; + + @override + Widget build(BuildContext context) { + return FutureBuilder<List<AppAccent>>( + future: accents, + builder: (context, snapshot) { + final found = snapshot.data ?? const <AppAccent>[]; + // AnimatedSize so the row slides in when the posters finish decoding + // rather than making the page jump under the user's thumb. + return AnimatedSize( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: found.isEmpty + ? const SizedBox(width: double.infinity) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsLabel('appearance.section_library'.tr()), + SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + child: LayoutBuilder( + builder: (context, constraints) { + // Laid out on the same six-column grid as the + // preset row above, so the two rows of circles + // line up instead of being two different sizes. + // Between one and six swatches can turn up, and a + // Wrap would stretch two of them into saucers. + const gap = 10.0; + const columns = 6; + final size = + (constraints.maxWidth - gap * (columns - 1)) / + columns; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final accent in found) + _Swatch( + size: size, + color: accent.base, + selected: selected.isCustom && + selected.base == accent.base, + onTap: () => onPick(accent), + semanticLabel: + 'appearance.accent_custom'.tr(), + ), + ], + ); + }, + ), + ), + ], + ), + SettingsFootnote('appearance.library_footnote'.tr()), + const SizedBox(height: 20), + ], + ), + ); + }, + ); + } +} + +/// A settings row that just does something, with no value to show. +class _ActionRow extends StatelessWidget { + const _ActionRow({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 11, 16, 11), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: AppColors.primary, size: 18), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + height: 1.3, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Darkness ──────────────────────────────────────────────────────────────── + +class _DarknessPicker extends StatelessWidget { + const _DarknessPicker({ + required this.accent, + required this.value, + required this.onChanged, + }); + + final AppAccent accent; + final AppDarkness value; + final ValueChanged<AppDarkness> onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 14, 12, 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _DarknessTile( + accent: accent, + darkness: AppDarkness.dark, + labelKey: 'appearance.darkness_dark', + descKey: 'appearance.darkness_dark_desc', + selected: value == AppDarkness.dark, + onTap: () => onChanged(AppDarkness.dark), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _DarknessTile( + accent: accent, + darkness: AppDarkness.black, + labelKey: 'appearance.darkness_black', + descKey: 'appearance.darkness_black_desc', + selected: value == AppDarkness.black, + onTap: () => onChanged(AppDarkness.black), + ), + ), + ], + ), + ); + } +} + +class _DarknessTile extends StatelessWidget { + const _DarknessTile({ + required this.accent, + required this.darkness, + required this.labelKey, + required this.descKey, + required this.selected, + required this.onTap, + }); + + final AppAccent accent; + final AppDarkness darkness; + final String labelKey; + final String descKey; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + // Each tile previews ITS OWN mode in the CURRENT accent — the whole point + // is to show the user the option they have not chosen. + final palette = AppPalette.resolve(accent: accent, darkness: darkness); + return Semantics( + label: labelKey.tr(), + selected: selected, + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.fromLTRB(9, 9, 9, 10), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(13), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.55) + : AppColors.textPrimary.withValues(alpha: 0.07), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DarknessSample(palette: palette), + const SizedBox(height: 9), + Row( + children: [ + Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + size: 15, + color: selected ? AppColors.primary : AppColors.textHint, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + labelKey.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: 13, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + descKey.tr(), + maxLines: 2, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + height: 1.3, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Reset ─────────────────────────────────────────────────────────────────── + +class _ResetAction extends StatelessWidget { + const _ResetAction(); + + @override + Widget build(BuildContext context) { + final theme = getIt<ThemeController>(); + if (theme.isDefault) return const SizedBox.shrink(); + return IconButton( + tooltip: 'appearance.reset_tooltip'.tr(), + icon: const Icon(Icons.settings_backup_restore_rounded, size: 22), + color: AppColors.textSecondary, + onPressed: () { + if (isMobilePlatform) HapticFeedback.selectionClick(); + theme.reset(); + }, + ); + } +} + +class _ResetRow extends StatelessWidget { + const _ResetRow({required this.enabled, required this.onReset}); + + final bool enabled; + final VoidCallback onReset; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: enabled ? onReset : null, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 13, 16, 13), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: AppColors.textSecondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.settings_backup_restore_rounded, + color: AppColors.textSecondary, + size: 18, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + 'appearance.reset'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ── Custom colour sheet ───────────────────────────────────────────────────── + +/// Opens the custom accent picker. Returns the chosen colour, or null if the +/// sheet was dismissed. +Future<Color?> showCustomAccentSheet( + BuildContext context, { + required Color initial, +}) { + return showModalBottomSheet<Color>( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + builder: (_) => _CustomAccentSheet(initial: initial), + ); +} + +class _CustomAccentSheet extends StatefulWidget { + const _CustomAccentSheet({required this.initial}); + + final Color initial; + + @override + State<_CustomAccentSheet> createState() => _CustomAccentSheetState(); +} + +class _CustomAccentSheetState extends State<_CustomAccentSheet> { + late HSLColor _hsl = HSLColor.fromColor(widget.initial); + + Color get _seed => _hsl.toColor(); + + /// What the app would actually use — the seed after the white-legibility + /// clamp. Showing both is what makes the clamp explicable instead of + /// mysterious. + AppAccent get _resolved => AppAccent.custom(_seed); + + bool get _wasAdjusted => + AppAccent.whiteContrast(_seed) < AppAccent.minWhiteContrast; + + void _update(HSLColor next) { + setState(() => _hsl = next); + } + + @override + Widget build(BuildContext context) { + final accent = _resolved; + final hex = accent.base + .toARGB32() + .toRadixString(16) + .padLeft(8, '0') + .substring(2) + .toUpperCase(); + + return SafeArea( + top: false, + child: SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 10, 18, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 38, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: AppColors.textHint.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + children: [ + Expanded( + child: Text( + 'appearance.custom_title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '#$hex', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13, + fontFeatures: [FontFeature.tabularFigures()], + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 14), + + // The candidate colour on the same surfaces the rest of the page + // uses. DarknessSample takes an explicit palette, which is what + // this needs — the colour being previewed is deliberately NOT the + // one installed yet. + Center( + child: SizedBox( + width: 240, + child: DarknessSample( + palette: AppPalette.resolve( + accent: accent, + darkness: AppPalette.current.darkness, + ), + ), + ), + ), + const SizedBox(height: 18), + + _GradientSlider( + label: 'appearance.custom_hue'.tr(), + value: _hsl.hue / 360, + thumbColor: HSLColor.fromAHSL(1, _hsl.hue, 1, 0.5).toColor(), + gradient: const [ + Color(0xFFFF0000), Color(0xFFFFFF00), Color(0xFF00FF00), + Color(0xFF00FFFF), Color(0xFF0000FF), Color(0xFFFF00FF), + Color(0xFFFF0000), + ], + onChanged: (t) => _update(_hsl.withHue((t * 360).clamp(0, 360))), + ), + const SizedBox(height: 14), + _GradientSlider( + label: 'appearance.custom_saturation'.tr(), + value: _hsl.saturation, + thumbColor: _seed, + gradient: [ + HSLColor.fromAHSL(1, _hsl.hue, 0, _hsl.lightness).toColor(), + HSLColor.fromAHSL(1, _hsl.hue, 1, _hsl.lightness).toColor(), + ], + onChanged: (t) => _update(_hsl.withSaturation(t)), + ), + const SizedBox(height: 14), + _GradientSlider( + label: 'appearance.custom_lightness'.tr(), + value: _hsl.lightness, + thumbColor: _seed, + gradient: [ + Colors.black, + HSLColor.fromAHSL(1, _hsl.hue, _hsl.saturation, 0.5).toColor(), + Colors.white, + ], + onChanged: (t) => _update(_hsl.withLightness(t)), + ), + + if (_wasAdjusted) ...[ + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.info_outline_rounded, + size: 15, + color: AppColors.textHint, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'appearance.custom_adjusted'.tr(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + height: 1.4, + ), + ), + ), + ], + ), + ], + + const SizedBox(height: 18), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + minimumSize: const Size(0, 46), + foregroundColor: AppColors.textSecondary, + ), + child: Text('general.cancel'.tr()), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(_seed), + style: FilledButton.styleFrom( + backgroundColor: accent.base, + foregroundColor: Colors.white, + minimumSize: const Size(0, 46), + ), + child: Text('appearance.custom_apply'.tr()), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// A gradient track with a draggable thumb, normalised to 0..1. +/// +/// Hand-rolled rather than a themed [Slider] because the track *is* the +/// information here: it has to be a real gradient of the values it selects, +/// edge to edge, with the thumb sitting exactly on the colour it represents. +/// A Slider reserves invisible padding for its overlay, which would push the +/// gradient out of alignment with the thumb. +class _GradientSlider extends StatelessWidget { + const _GradientSlider({ + required this.label, + required this.value, + required this.gradient, + required this.thumbColor, + required this.onChanged, + }); + + final String label; + final double value; + final List<Color> gradient; + final Color thumbColor; + final ValueChanged<double> onChanged; + + static const double _trackHeight = 14; + static const double _thumbRadius = 12; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + // The thumb's centre travels between the two inset edges, so the + // track it walks along is shortened by one radius at each end. + final usable = constraints.maxWidth - _thumbRadius * 2; + void emit(double dx) { + if (usable <= 0) return; + onChanged(((dx - _thumbRadius) / usable).clamp(0.0, 1.0)); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => emit(d.localPosition.dx), + onHorizontalDragStart: (d) => emit(d.localPosition.dx), + onHorizontalDragUpdate: (d) => emit(d.localPosition.dx), + child: SizedBox( + height: _thumbRadius * 2 + 8, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: _thumbRadius, + ), + child: Container( + height: _trackHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_trackHeight / 2), + gradient: LinearGradient(colors: gradient), + border: Border.all( + color: AppColors.textPrimary.withValues(alpha: 0.10), + width: 0.5, + ), + ), + ), + ), + Positioned( + left: usable.clamp(0, double.infinity) * value.clamp(0.0, 1.0), + child: Container( + width: _thumbRadius * 2, + height: _thumbRadius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: thumbColor, + border: Border.all(color: Colors.white, width: 2.5), + boxShadow: const [ + BoxShadow( + color: Color(0x66000000), + blurRadius: 5, + offset: Offset(0, 1), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/profile/presentation/pages/player_settings_page.dart b/lib/features/profile/presentation/pages/player_settings_page.dart index 0f0c9362..ae945e27 100644 --- a/lib/features/profile/presentation/pages/player_settings_page.dart +++ b/lib/features/profile/presentation/pages/player_settings_page.dart @@ -688,7 +688,7 @@ class _EngineRow extends StatelessWidget { ), child: Text( 'profile.player_engine_badge_default'.tr(), - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 10, fontWeight: FontWeight.w700, diff --git a/lib/features/profile/presentation/pages/profile_edit_page.dart b/lib/features/profile/presentation/pages/profile_edit_page.dart index ff9fa8f6..8cced0ca 100644 --- a/lib/features/profile/presentation/pages/profile_edit_page.dart +++ b/lib/features/profile/presentation/pages/profile_edit_page.dart @@ -311,7 +311,7 @@ class _AvatarPicker extends StatelessWidget { ), clipBehavior: Clip.antiAlias, child: uploading - ? const Center( + ? Center( child: SizedBox( width: 24, height: 24, diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 5fc3cebd..0ca968ba 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -15,6 +15,9 @@ import 'package:soplay/core/aniyomi/aniyomi_channel.dart'; import 'package:soplay/core/cloudstream/cloudstream_channel.dart'; import 'package:soplay/core/bridge/bridge_control.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; +import 'package:soplay/core/theme/theme_controller.dart'; +import 'package:soplay/features/profile/presentation/pages/appearance_page.dart'; import 'package:soplay/features/extensions/data/mangayomi_runtime.dart'; import 'package:soplay/features/extensions/presentation/pages/mangayomi_sources_page.dart'; import 'package:soplay/features/aniyomi/presentation/pages/aniyomi_sources_page.dart'; @@ -43,6 +46,7 @@ import 'package:soplay/features/mal/data/mal_service.dart'; import 'package:soplay/features/mal/presentation/widgets/mal_brand.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; class ProfilePage extends StatelessWidget { const ProfilePage({super.key}); @@ -108,20 +112,24 @@ class _ProfileViewState extends State<_ProfileView> { backgroundColor: AppColors.background, body: Stack( children: [ - const DecoratedBox( + // Accent-tinted at the top, falling to the page background. Used to + // be the literals [#1E1416, #181818, #101010]; those are exactly what + // these three resolve to at the default red, and they now follow the + // chosen accent and darkness instead of staying red on a blue app. + DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Color(0xFF1E1416), - Color(0xFF181818), - Color(0xFF101010), + AppColors.heroTop, + AppColors.heroMid, + AppColors.heroBottom, ], - stops: [0, 0.35, 1], + stops: const [0, 0.35, 1], ), ), - child: SizedBox.expand(), + child: const SizedBox.expand(), ), _ProfileScrollFrame( child: RefreshIndicator( @@ -134,60 +142,58 @@ class _ProfileViewState extends State<_ProfileView> { physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter(child: SizedBox(height: headerH + 16)), + // One BlocBuilder over the whole list rather than a stack of + // fixed slivers with conditionals sprinkled through it. + // + // A guest cannot use the streak, a tracker connection or a TV + // pairing — all three bind to a Sozo account — and the old + // list still reserved their gaps, so signed out the page was + // a run of empty space with Settings pushed below the fold. + // Building the sections into a list means the spacing belongs + // to the sections that are actually there, and the reveal + // stagger renumbers itself. SliverToBoxAdapter( child: BlocBuilder<AuthBloc, AuthState>( builder: (context, state) { - final user = state is AuthLoaded - ? state.token.user - : null; - return _Reveal( - order: 0, - child: _ProfileHeader(user: user), + final signedIn = state is AuthLoaded; + final user = signedIn ? state.token.user : null; + final sections = <Widget>[ + _ProfileHeader(user: user), + if (signedIn) const StreakCard(), + if (signedIn) const _ConnectionsSection(), + const _ContentSection(), + // signedIn is passed rather than read inside: a + // `const _WatchHistorySection()` is the same widget + // instance every build, so Flutter would skip + // rebuilding it and the Watch Party row would not + // appear until something else disturbed the tree. + _WatchHistorySection(signedIn: signedIn), + const _SettingsEntriesSection(), + const _AboutSection(), + if (signedIn) const _SignOutSection(), + ]; + return Column( + children: [ + for (var i = 0; i < sections.length; i++) ...[ + // The header carries its own padding; the gap + // after it is the wider one it always had. + if (i > 0) SizedBox(height: i == 1 ? 20 : 16), + _Reveal( + // Keyed by section type, which is unique in + // this list: signing out removes three entries + // and every section below shifts index, and an + // unkeyed Column would hand each one the + // previous occupant's Element and State. + key: ValueKey<Type>(sections[i].runtimeType), + order: i, + child: sections[i], + ), + ], + ], ); }, ), ), - const SliverToBoxAdapter(child: SizedBox(height: 20)), - const SliverToBoxAdapter( - child: _Reveal(order: 1, child: StreakCard()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 2, child: _ConnectionsSection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 3, child: _ProvidersSection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 4, child: _ExtensionSourcesSection()), - ), - // Signed-in only: approving a TV pairing binds it to an account, so - // there is nothing this can do for a guest. - const SliverToBoxAdapter( - child: _Reveal(order: 5, child: _WatchHistorySection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 6, child: _SecuritySection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 7, child: _SettingsEntriesSection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 8, child: _AboutSection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - SliverToBoxAdapter( - child: BlocBuilder<AuthBloc, AuthState>( - builder: (context, state) => state is AuthLoaded - ? const _Reveal(order: 9, child: _SignOutSection()) - : const SizedBox.shrink(), - ), - ), SliverToBoxAdapter( child: SizedBox( height: bottomPad + (isDesktopPlatform ? 112 : 96), @@ -344,13 +350,29 @@ class _ProfileViewState extends State<_ProfileView> { ], ); case 1: - return const _ProvidersSection(); + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ProvidersSection(), + SizedBox(height: 20), + _ExtensionSourcesSection(), + ], + ); case 2: return const _WatchHistorySection(); case 3: return const _SecuritySection(); case 4: - return const _AppearanceSection(); + // Theme first, then the window / navigation-bar options that were + // already here — one Appearance panel rather than two half-panels. + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppearanceSettings(showResetRow: true), + SizedBox(height: 20), + _AppearanceSection(), + ], + ); default: return const _AboutSection(); } @@ -470,7 +492,7 @@ class _SettingsNavItemState extends State<_SettingsNavItem> { /// Staggered fade + slide-up entrance for each settings section (desktop only, /// akuse-style). Mobile returns the child unchanged. class _Reveal extends StatefulWidget { - const _Reveal({required this.order, required this.child}); + const _Reveal({super.key, required this.order, required this.child}); final int order; final Widget child; @@ -593,7 +615,7 @@ class _GuestContent extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.2), ), ), - child: const Icon( + child: Icon( Icons.person_outline_rounded, color: AppColors.primaryLight, size: 28, @@ -636,7 +658,7 @@ class _GuestContent extends StatelessWidget { label: Text('profile.sign_in'.tr()), style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), ), @@ -788,6 +810,10 @@ class _SignOutSection extends StatelessWidget { class _ProvidersSection extends StatelessWidget { const _ProvidersSection(); + /// The provider row on its own, so the mobile CONTENT card can put it above + /// the extension sources rather than give it a labelled card of its own. + static Widget row() => const _ProviderRow(); + @override Widget build(BuildContext context) { return Padding( @@ -796,7 +822,19 @@ class _ProvidersSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionLabel('profile.section_providers'.tr()), - BlocBuilder<ProviderBloc, ProviderState>( + _SectionCard(children: [row()]), + ], + ), + ); + } +} + +class _ProviderRow extends StatelessWidget { + const _ProviderRow(); + + @override + Widget build(BuildContext context) { + return BlocBuilder<ProviderBloc, ProviderState>( builder: (context, state) { final loaded = state is ProviderLoaded ? state : null; final currentProvider = loaded?.currentProvider; @@ -804,9 +842,7 @@ class _ProvidersSection extends StatelessWidget { currentProvider?.name ?? loaded?.currentProviderId ?? '—'; final total = loaded?.providers.length ?? 0; - return _SectionCard( - children: [ - _Tile( + return _Tile( icon: Icons.movie_filter_outlined, title: 'profile.provider'.tr(), trailing: Row( @@ -859,14 +895,9 @@ class _ProvidersSection extends StatelessWidget { ], ), onTap: () => context.push('/providers'), - ), - ], - ); + ); }, - ), - ], - ), - ); + ); } } @@ -875,7 +906,10 @@ void openProviderPicker(BuildContext context, ProviderBloc bloc) { } class _WatchHistorySection extends StatefulWidget { - const _WatchHistorySection(); + const _WatchHistorySection({this.signedIn = true}); + + /// Hides the rows that only work with a Sozo account. + final bool signedIn; @override State<_WatchHistorySection> createState() => _WatchHistorySectionState(); @@ -968,6 +1002,37 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { trailing: const _TileChevron(), onTap: () => context.push('/following'), ), + // Moved out of the home top bar, which had five permanent icons + // and no room. Each of these is somewhere you go on purpose, and + // this list is where the app already keeps those; leaving them + // only in the bar was the reason the bar could not be trimmed. + // + // Watch Party is the one that hard-requires an account — tapping + // it signed out only bounces to /login — so a guest is not shown + // a door that opens onto a sign-in wall. + if (widget.signedIn) ...[ + const _TileDivider(), + _Tile( + icon: Icons.groups_rounded, + title: 'watch_party.title'.tr(), + trailing: const _TileChevron(), + onTap: () => showPartyEntrySheet(context), + ), + ], + const _TileDivider(), + _Tile( + icon: Icons.track_changes_rounded, + title: 'navigation.anilist'.tr(), + trailing: const _TileChevron(), + onTap: () => context.push('/anilist'), + ), + const _TileDivider(), + _Tile( + icon: Icons.live_tv_rounded, + title: 'navigation.live_tv'.tr(), + trailing: const _TileChevron(), + onTap: () => context.push('/live-tv'), + ), const _TileDivider(), _Tile( icon: Icons.devices_rounded, @@ -1185,18 +1250,40 @@ class _SecuritySection extends StatefulWidget { } class _SecuritySectionState extends State<_SecuritySection> { - late final AppLockRepository _lock = getIt<AppLockRepository>(); - @override Widget build(BuildContext context) { - final enabled = _lock.isEnabled; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionLabel('app_lock.section_label'.tr()), - _SectionCard( + const _SectionCard(children: [_SecurityRows()]), + ], + ), + ); + } +} + +/// App lock + private list, as two bare rows. +/// +/// Split out of [_SecuritySection] so the mobile SETTINGS card can hold them +/// next to Appearance and Player — where they belong — while desktop keeps its +/// own "Security" panel. +class _SecurityRows extends StatefulWidget { + const _SecurityRows(); + + @override + State<_SecurityRows> createState() => _SecurityRowsState(); +} + +class _SecurityRowsState extends State<_SecurityRows> { + late final AppLockRepository _lock = getIt<AppLockRepository>(); + + @override + Widget build(BuildContext context) { + final enabled = _lock.isEnabled; + return Column( children: [ _Tile( icon: Icons.lock_rounded, @@ -1238,10 +1325,7 @@ class _SecuritySectionState extends State<_SecuritySection> { }, ), ], - ), - ], - ), - ); + ); } } @@ -1540,12 +1624,12 @@ class NavbarPage extends StatelessWidget { } } -/// The two rows that open a settings sub-page ([NavbarPage], -/// [PlayerSettingsPage]). +/// Everything the user can configure, in ONE card. /// -/// One labelled card rather than two unlabelled single-row cards: floating -/// alone between the labelled Security and About sections, they read as -/// leftovers rather than as a group. +/// Appearance, the navigation bar and the player used to be a three-row card +/// with app lock and the private list in a separate labelled "Security" card +/// directly above it. Two labels for five rows that are all "settings" made the +/// page read as longer than it is; one label reads as one place to look. /// /// The Player row is unconditional — the page behind it owns playback defaults /// and subtitle appearance, which apply everywhere; only its engine block is @@ -1563,6 +1647,14 @@ class _SettingsEntriesSection extends StatelessWidget { _SectionLabel('profile.section_settings'.tr()), _SectionCard( children: [ + _Tile( + icon: Icons.palette_outlined, + title: 'appearance.title'.tr(), + subtitle: 'appearance.entry_subtitle'.tr(), + trailing: const _AccentDotChevron(), + onTap: () => context.push('/appearance'), + ), + const _TileDivider(), _Tile( icon: Icons.view_week_rounded, title: 'profile.nav_style'.tr(), @@ -1576,6 +1668,90 @@ class _SettingsEntriesSection extends StatelessWidget { trailing: const _TileChevron(), onTap: () => context.push('/player-settings'), ), + const _TileDivider(), + const _SecurityRows(), + ], + ), + ], + ), + ); + } +} + +/// Where the content comes from: the active provider, and — folded away behind +/// one row — the installable extension ecosystems. +/// +/// These were two labelled cards and five rows. Four of those rows were +/// extension stores most people open once and never again, so they are collapsed +/// by default; the row still says how many there are, and opens them in place. +class _ContentSection extends StatefulWidget { + const _ContentSection(); + + @override + State<_ContentSection> createState() => _ContentSectionState(); +} + +class _ContentSectionState extends State<_ContentSection> { + bool _sourcesOpen = false; + + @override + Widget build(BuildContext context) { + final sources = _ExtensionSourcesSection.rowsFor(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel('profile.section_content'.tr()), + _SectionCard( + children: [ + _ProvidersSection.row(), + if (sources.isNotEmpty) ...[ + const _TileDivider(), + _Tile( + icon: Icons.extension_outlined, + title: 'profile.sources_row'.tr(), + subtitle: 'profile.sources_row_subtitle'.tr(), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${sources.length}', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + AnimatedRotation( + turns: _sourcesOpen ? 0.25 : 0, + duration: const Duration(milliseconds: 200), + child: const _TileChevron(), + ), + ], + ), + onTap: () => setState(() => _sourcesOpen = !_sourcesOpen), + ), + // AnimatedSize rather than a route: the stores are one tap + // away either way, and expanding in place keeps the user's + // place on a long page. + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _sourcesOpen + ? Column( + children: [ + for (final row in sources) ...[ + const _TileDivider(), + row, + ], + ], + ) + : const SizedBox(width: double.infinity), + ), + ], ], ), ], @@ -1858,6 +2034,10 @@ class _SectionCard extends StatelessWidget { } } +/// A section heading, with the same accent tick Home puts in front of every +/// row title. Two jobs: it carries the chosen colour down a screen that is +/// otherwise all greys, and it gives a long settings list a visual rhythm so +/// the sections read as separate rather than as one endless column. class _SectionLabel extends StatelessWidget { const _SectionLabel(this.label); final String label; @@ -1866,14 +2046,34 @@ class _SectionLabel extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - label, - style: const TextStyle( - color: AppColors.textHint, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - ), + child: Row( + children: [ + Container( + width: 2.5, + height: 11, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.primary, + AppColors.primary.withValues(alpha: 0.5), + ], + ), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 7), + Text( + label, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ], ), ); } @@ -2012,7 +2212,39 @@ class _TileDivider extends StatelessWidget { @override Widget build(BuildContext context) => - const Divider(color: AppColors.divider, height: 1, indent: 64); + Divider(color: AppColors.divider, height: 1, indent: 64); +} + +/// Chevron with the accent in front of it, for the Appearance row. +/// +/// The row's whole subject is a colour, so the current one belongs on the row — +/// it turns "Appearance ›" into an answer as well as a destination. +class _AccentDotChevron extends StatelessWidget { + const _AccentDotChevron(); + + @override + Widget build(BuildContext context) { + final accent = getIt<ThemeController>().accent; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: accent.base, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.18), + width: 0.8, + ), + ), + ), + const SizedBox(width: 8), + const _TileChevron(), + ], + ); + } } /// Chevron used by every row that opens something. @@ -2162,8 +2394,10 @@ class _ServerCountdownTileState extends State<_ServerCountdownTile> { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( + // "Expired" means the server is down and nothing will load. + // That is an error, not a place to show the theme. color: rem == Duration.zero - ? AppColors.primary + ? AppColors.error : AppColors.textSecondary, fontSize: 13, fontFeatures: const [FontFeature.tabularFigures()], @@ -2233,14 +2467,15 @@ class _ServerSupportSheet extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.08), + color: AppColors.error.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), ), child: Text( + // Same reasoning as the countdown row above it. 'profile.server_expired'.tr(), textAlign: TextAlign.center, style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontSize: 15, fontWeight: FontWeight.w700, ), @@ -2353,9 +2588,9 @@ class _SheetCountdownCell extends StatelessWidget { class _ExtensionSourcesSection extends StatelessWidget { const _ExtensionSourcesSection(); - @override - Widget build(BuildContext context) { - final rows = <Widget>[ + /// The rows themselves, so the mobile CONTENT card can host them behind an + /// expander instead of standing up a fifth labelled card of its own. + static List<Widget> rowsFor(BuildContext context) => <Widget>[ if (BridgeControl.canHost && CloudStreamChannel.isSupported) _Tile( leading: const _TileLogo( @@ -2412,6 +2647,10 @@ class _ExtensionSourcesSection extends StatelessWidget { ), ), ]; + + @override + Widget build(BuildContext context) { + final rows = rowsFor(context); if (rows.isEmpty) return const SizedBox.shrink(); return Padding( diff --git a/lib/features/profile/presentation/pages/providers_page.dart b/lib/features/profile/presentation/pages/providers_page.dart index 7824a0fa..11644667 100644 --- a/lib/features/profile/presentation/pages/providers_page.dart +++ b/lib/features/profile/presentation/pages/providers_page.dart @@ -734,7 +734,7 @@ class _ProviderListTile extends StatelessWidget { Container( width: 20, height: 20, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), diff --git a/lib/features/profile/presentation/widgets/library_accents.dart b/lib/features/profile/presentation/widgets/library_accents.dart new file mode 100644 index 00000000..36cb4e96 --- /dev/null +++ b/lib/features/profile/presentation/widgets/library_accents.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:palette_generator/palette_generator.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_accent.dart'; +import 'package:soplay/features/history/data/history_service.dart'; + +/// Accents pulled out of the posters of what the user actually watches. +/// +/// The idea is that the best accent for a person's app is usually already on +/// their home screen — the colour of the show they are three episodes into. So +/// instead of only offering twelve colours somebody else chose, Appearance +/// offers the ones their own library is made of. +/// +/// Every extracted colour still goes through [AppAccent.custom], so a poster's +/// pale cream or near-black is pulled into the legible band like any other +/// custom pick — the swatch row can never produce an unreadable theme. +class LibraryAccents { + LibraryAccents._(); + + /// How many recent titles to read. Each one is a network image decode, so + /// this is deliberately small — the row is a nice-to-have on a settings page, + /// not something worth spending a user's data on. + static const int _maxPosters = 5; + + /// How many swatches the row shows. + static const int _maxSwatches = 6; + + /// Beyond this the extraction is abandoned. A settings page that hangs + /// waiting for a poster is worse than one that quietly has no suggestions. + static const Duration _budget = Duration(seconds: 6); + + /// Resolved once per app run. The posters do not change while the user is on + /// the Appearance page, and this page rebuilds on every colour tap — without + /// the cache, every tap would re-decode five images. + static Future<List<AppAccent>>? _cached; + + static Future<List<AppAccent>> load() => _cached ??= _extract(); + + /// Drops the cache, so a later visit re-reads a library that has since + /// changed. Called when history is cleared. + static void invalidate() => _cached = null; + + static Future<List<AppAccent>> _extract() async { + try { + return await _run().timeout(_budget); + } catch (_) { + // Offline, an expired poster URL, a decode failure — all of them mean + // the same thing here: no suggestions, and no error to show for it. + return const []; + } + } + + static Future<List<AppAccent>> _run() async { + final history = getIt<HistoryService>().getAll(); + final urls = <String>[]; + for (final item in history) { + final thumb = item.thumbnail; + if (thumb == null || thumb.isEmpty) continue; + if (!thumb.startsWith('http')) continue; + if (urls.contains(thumb)) continue; + urls.add(thumb); + if (urls.length >= _maxPosters) break; + } + if (urls.isEmpty) return const []; + + // In parallel: five sequential decodes would routinely exceed the budget, + // and a failure on one poster must not cost the other four. + final palettes = await Future.wait( + urls.map(_paletteOf), + eagerError: false, + ); + + final picked = <AppAccent>[]; + final takenHues = <int>{}; + for (final palette in palettes) { + if (palette == null) continue; + for (final candidate in _candidatesOf(palette)) { + final accent = AppAccent.custom(candidate); + // Bucket by 30° of hue so five posters from the same franchise cannot + // fill the row with five near-identical blues. + final bucket = (HSLColor.fromColor(accent.base).hue ~/ 30).clamp(0, 11); + if (!takenHues.add(bucket)) continue; + picked.add(accent); + if (picked.length >= _maxSwatches) return picked; + break; // one colour per poster, so the row reads as one per title + } + } + return picked; + } + + static Future<PaletteGenerator?> _paletteOf(String url) async { + try { + return await PaletteGenerator.fromImageProvider( + NetworkImage(url), + // Downscaled hard: the palette of a poster survives a 64px thumbnail, + // and decoding full-size art for a settings row would not be a fair + // trade for the user's memory. + size: const Size(64, 64), + maximumColorCount: 8, + ); + } catch (_) { + return null; + } + } + + /// Ordered by how much of the poster's character each one carries. + static Iterable<Color> _candidatesOf(PaletteGenerator palette) sync* { + for (final swatch in [ + palette.vibrantColor, + palette.darkVibrantColor, + palette.lightVibrantColor, + palette.dominantColor, + palette.mutedColor, + ]) { + if (swatch != null) yield swatch.color; + } + } +} diff --git a/lib/features/profile/presentation/widgets/settings_tiles.dart b/lib/features/profile/presentation/widgets/settings_tiles.dart index aca8c4ba..3d1f2de3 100644 --- a/lib/features/profile/presentation/widgets/settings_tiles.dart +++ b/lib/features/profile/presentation/widgets/settings_tiles.dart @@ -80,7 +80,7 @@ class SettingsDivider extends StatelessWidget { @override Widget build(BuildContext context) => - const Divider(color: AppColors.divider, height: 1, indent: 64); + Divider(color: AppColors.divider, height: 1, indent: 64); } /// A row that opens a menu of mutually exclusive values, with the current one @@ -152,7 +152,7 @@ class SettingsDropdownTile<T> extends StatelessWidget { ), ), if (option == value) - const Icon( + Icon( Icons.check_rounded, color: AppColors.primary, size: 17, diff --git a/lib/features/profile/presentation/widgets/tab_customizer_sheet.dart b/lib/features/profile/presentation/widgets/tab_customizer_sheet.dart index fc0b1bde..258b55af 100644 --- a/lib/features/profile/presentation/widgets/tab_customizer_sheet.dart +++ b/lib/features/profile/presentation/widgets/tab_customizer_sheet.dart @@ -5,6 +5,7 @@ import 'package:soplay/core/navigation/app_tab.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/nav_prefs.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; /// Bottom-bar customizer (Settings → Appearance). Ports satashkent's /// quick-nav customizer to soplay tokens: a draft copy edited freely, persisted @@ -336,7 +337,7 @@ class _Footer extends StatelessWidget { 16, MediaQuery.paddingOf(context).bottom + 12, ), - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border(top: BorderSide(color: AppColors.divider, width: 1)), ), child: Row( @@ -362,7 +363,7 @@ class _Footer extends StatelessWidget { style: FilledButton.styleFrom( backgroundColor: AppColors.primary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text('nav_customize.save'.tr()), diff --git a/lib/features/profile/presentation/widgets/theme_preview.dart b/lib/features/profile/presentation/widgets/theme_preview.dart new file mode 100644 index 00000000..55582649 --- /dev/null +++ b/lib/features/profile/presentation/widgets/theme_preview.dart @@ -0,0 +1,381 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_palette.dart'; +import 'package:soplay/core/theme/app_theme.dart'; + +/// What the chosen colours actually do, shown on the app's own controls. +/// +/// ## Why this is not a picture of a screen +/// +/// The obvious thing to build here is a little phone with the Home page inside +/// it. It was built, twice, and thrown away both times — because it cannot be +/// true. The real Home is a network of blocs, cached artwork and live rows; +/// nothing that paints instantly inside a settings list can be that page. What +/// gets drawn instead is a *guess* at it: invented titles, invented posters, a +/// layout that drifts from the real one the moment anybody touches Home. A +/// preview that misrepresents the app is worse than no preview, and it is what +/// made the earlier versions read as fake. +/// +/// So this shows no screen at all. It shows the **real controls** — a real +/// [ElevatedButton], a real switch, a real card on a real background, the real +/// section tick, a real progress bar — at their real size, in the palette being +/// chosen. Every pixel here is something the user will meet again unchanged. +class ThemePreview extends StatelessWidget { + const ThemePreview({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 16), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(kFieldRadius), + border: Border.all(color: AppColors.border, width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SampleSectionHead(), + const SizedBox(height: 10), + const _SampleCard(), + const SizedBox(height: 14), + const _SampleProgress(), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 3, + child: SizedBox( + height: kButtonHeight, + child: ElevatedButton( + // Inert on purpose: this is a swatch of the button, not a + // button. Disabling it would show the disabled colours. + onPressed: () {}, + child: Text('detail.play'.tr()), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: SizedBox( + height: kButtonHeight, + child: OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, kButtonHeight), + padding: EdgeInsets.zero, + side: BorderSide( + color: AppColors.textPrimary.withValues(alpha: 0.22), + width: 1.2, + ), + ), + child: Text( + 'general.cancel'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// The accent tick every section header on Home and Profile carries. +class _SampleSectionHead extends StatelessWidget { + const _SampleSectionHead(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 3, + height: 15, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.primary, + AppColors.primary.withValues(alpha: 0.55), + ], + ), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 9), + Text( + 'home.continue_watching'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + const Spacer(), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + ], + ); + } +} + +/// A card on the page background, with the app's own 5%-white hairline — the +/// pair that tells you what AMOLED does. On true black the fill nearly +/// disappears and the hairline becomes the edge. +class _SampleCard extends StatelessWidget { + const _SampleCard(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: AppColors.textPrimary.withValues(alpha: 0.05), + width: 0.5, + ), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _SampleRow( + icon: Icons.palette_outlined, + title: 'appearance.title'.tr(), + trailing: _SampleSwitch(), + ), + Divider(color: AppColors.divider, height: 1, indent: 60), + _SampleRow( + icon: Icons.play_circle_outline_rounded, + title: 'profile.section_player'.tr(), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'general.done'.tr(), + style: TextStyle( + color: AppColors.primary, + fontSize: 13.5, + fontWeight: FontWeight.w600, + ), + ), + Icon( + Icons.arrow_drop_down_rounded, + color: AppColors.primary, + size: 22, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SampleRow extends StatelessWidget { + const _SampleRow({ + required this.icon, + required this.title, + required this.trailing, + }); + + final IconData icon; + final String title; + final Widget trailing; + + @override + Widget build(BuildContext context) { + return Padding( + // The metrics from settings_tiles.dart, so the sample row and the real + // rows under it sit on one grid. + padding: const EdgeInsets.fromLTRB(14, 11, 12, 11), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.textSecondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: AppColors.textSecondary, size: 17), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + trailing, + ], + ), + ); + } +} + +/// The app's switch in its on state — accent track, white thumb. +class _SampleSwitch extends StatelessWidget { + const _SampleSwitch(); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Switch.adaptive( + value: true, + onChanged: (_) {}, + activeThumbColor: Colors.white, + activeTrackColor: AppColors.primary, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } +} + +/// A watched-progress bar, the accent's most common appearance in the app. +class _SampleProgress extends StatelessWidget { + const _SampleProgress(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: 0.62, + minHeight: 4, + backgroundColor: AppColors.surfaceVariant, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 10), + Text( + '62%', + style: TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ); + } +} + +/// The two darkness levels, side by side, as what they actually are: a stack of +/// surfaces. No pretend screen — just the page colour, a card on it, its +/// hairline, and the accent, in the level being described. +class DarknessSample extends StatelessWidget { + const DarknessSample({super.key, required this.palette}); + + final AppPalette palette; + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 16 / 10, + child: Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: palette.background, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: palette.border, width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration( + color: palette.surface, + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: AppColors.textPrimary.withValues(alpha: 0.05), + width: 0.5, + ), + ), + child: Row( + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: palette.primary, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 7), + Expanded( + child: Container( + height: 4, + decoration: BoxDecoration( + color: AppColors.textPrimary.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 7), + SizedBox( + height: 12, + child: Row( + children: [ + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: palette.card, + borderRadius: BorderRadius.circular(4), + ), + child: const SizedBox.expand(), + ), + ), + const SizedBox(width: 6), + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: palette.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + child: const SizedBox.expand(), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/remote/presentation/pages/tv_remote_page.dart b/lib/features/remote/presentation/pages/tv_remote_page.dart index c346d804..d6dca5d7 100644 --- a/lib/features/remote/presentation/pages/tv_remote_page.dart +++ b/lib/features/remote/presentation/pages/tv_remote_page.dart @@ -276,7 +276,7 @@ class _ConnectionBanner extends StatelessWidget { minHeight: 3, value: (playing!.positionMs ?? 0) / playing.durationMs!, backgroundColor: AppColors.surfaceVariant, - valueColor: const AlwaysStoppedAnimation(AppColors.primary), + valueColor: AlwaysStoppedAnimation(AppColors.primary), ), ), const SizedBox(height: 4), diff --git a/lib/features/reports/presentation/widgets/report_sheet.dart b/lib/features/reports/presentation/widgets/report_sheet.dart index 678455e8..99ef9fa4 100644 --- a/lib/features/reports/presentation/widgets/report_sheet.dart +++ b/lib/features/reports/presentation/widgets/report_sheet.dart @@ -4,6 +4,7 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/reports/domain/entities/report_payload.dart'; import 'package:soplay/features/reports/domain/repositories/reports_repository.dart'; @@ -178,7 +179,7 @@ class _ReportSheetState extends State<_ReportSheet> { backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: _sending ? null : _submit, diff --git a/lib/features/search/presentation/pages/cross_search_page.dart b/lib/features/search/presentation/pages/cross_search_page.dart index 5201ac73..9a4c2500 100644 --- a/lib/features/search/presentation/pages/cross_search_page.dart +++ b/lib/features/search/presentation/pages/cross_search_page.dart @@ -201,7 +201,7 @@ class _CrossSearchPageState extends State<CrossSearchPage> { final hit = title.hits[i]; return ListTile( dense: true, - leading: const Icon(Icons.play_circle_outline, + leading: Icon(Icons.play_circle_outline, color: AppColors.primary, size: 20), title: Text(hit.provider.name, style: @@ -608,7 +608,7 @@ class _CrossSearchPageState extends State<CrossSearchPage> { borderRadius: BorderRadius.circular(10), ), child: Text('${r.items.length}', - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 11, fontWeight: FontWeight.w700)), diff --git a/lib/features/search/presentation/widgets/search_filter_sheet.dart b/lib/features/search/presentation/widgets/search_filter_sheet.dart index 1f728de0..f1571ab7 100644 --- a/lib/features/search/presentation/widgets/search_filter_sheet.dart +++ b/lib/features/search/presentation/widgets/search_filter_sheet.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/search/domain/entities/genre_entity.dart'; @@ -119,7 +120,7 @@ class _SearchFilterSheetState extends State<SearchFilterSheet> { ), padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( @@ -142,7 +143,7 @@ class _SearchFilterSheetState extends State<SearchFilterSheet> { elevation: 0, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( diff --git a/lib/features/search/presentation/widgets/search_header.dart b/lib/features/search/presentation/widgets/search_header.dart index 3fb44317..e49f4d01 100644 --- a/lib/features/search/presentation/widgets/search_header.dart +++ b/lib/features/search/presentation/widgets/search_header.dart @@ -47,7 +47,7 @@ class SearchStickyHeader extends StatelessWidget { final bottomGap = lerpDouble(16, 10, compactProgress)!; final blurred = progress > 0.01; final backgroundColor = blurred - ? const Color(0xFF181818).withValues(alpha: 0.82) + ? AppColors.background.withValues(alpha: 0.82) : AppColors.background; final inner = Column( @@ -314,7 +314,7 @@ class _FilterButton extends StatelessWidget { child: Container( width: 7, height: 7, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), diff --git a/lib/features/search/presentation/widgets/search_set_sheet.dart b/lib/features/search/presentation/widgets/search_set_sheet.dart index c824a0e4..ef242d13 100644 --- a/lib/features/search/presentation/widgets/search_set_sheet.dart +++ b/lib/features/search/presentation/widgets/search_set_sheet.dart @@ -83,7 +83,7 @@ class _SearchSetSheetState extends State<SearchSetSheet> { maxChildSize: 0.95, expand: false, builder: (context, scrollController) => Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/features/search/presentation/widgets/search_state_views.dart b/lib/features/search/presentation/widgets/search_state_views.dart index 70d55c31..2bccfc00 100644 --- a/lib/features/search/presentation/widgets/search_state_views.dart +++ b/lib/features/search/presentation/widgets/search_state_views.dart @@ -44,7 +44,7 @@ class SearchContentView extends StatelessWidget { slivers: [ SliverToBoxAdapter(child: SizedBox(height: topPad)), if (state.status == SearchStatus.refreshing) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: SizedBox( height: 2, child: LinearProgressIndicator( @@ -104,7 +104,7 @@ class SearchContentView extends StatelessWidget { return [ SearchResultsGrid(items: state.items), if (state.isLoadingMore) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Center( @@ -540,7 +540,7 @@ class _ActionChip extends StatelessWidget { const SizedBox(width: 8), Text( label, - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 14, fontWeight: FontWeight.w600, diff --git a/lib/features/shorts/presentation/pages/shorts_page.dart b/lib/features/shorts/presentation/pages/shorts_page.dart index cbccd1c3..a190ef3b 100644 --- a/lib/features/shorts/presentation/pages/shorts_page.dart +++ b/lib/features/shorts/presentation/pages/shorts_page.dart @@ -323,7 +323,7 @@ class _ShortsViewState extends State<_ShortsView> top: topPad, left: 0, right: 0, - child: const LinearProgressIndicator( + child: LinearProgressIndicator( minHeight: 2, color: AppColors.primary, backgroundColor: Colors.transparent, diff --git a/lib/features/shorts/presentation/widgets/short_reel_item.dart b/lib/features/shorts/presentation/widgets/short_reel_item.dart index e1ebd8ad..b42040fb 100644 --- a/lib/features/shorts/presentation/widgets/short_reel_item.dart +++ b/lib/features/shorts/presentation/widgets/short_reel_item.dart @@ -7,6 +7,7 @@ import 'package:soplay/features/reports/domain/entities/report_payload.dart'; import 'package:soplay/features/reports/presentation/widgets/report_sheet.dart'; import 'package:soplay/core/player/media_controller.dart'; import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/theme/app_colors.dart'; import '../../domain/entities/short_entity.dart'; @@ -685,7 +686,7 @@ class _ShortReelItemState extends State<ShortReelItem> s.contentThumbnail, fit: BoxFit.cover, errorBuilder: (_, e, st) => Container( - color: const Color(0xFF2A2A2A), + color: AppColors.surfaceVariant, child: const Icon( Icons.movie_rounded, color: Colors.white54, @@ -830,14 +831,15 @@ class _ShortReelItemState extends State<ShortReelItem> padding: const EdgeInsets.only(left: 4, right: 14), decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), - gradient: const LinearGradient( - colors: [Color(0xFFE53935), Color(0xFFB71C1C)], + // Was a second, hard-coded red — the accent's job, duplicated. + gradient: LinearGradient( + colors: [AppColors.primary, AppColors.primaryDark], begin: Alignment.topLeft, end: Alignment.bottomRight, ), boxShadow: [ BoxShadow( - color: Colors.red.withValues(alpha: 0.3), + color: AppColors.primary.withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 4), ), @@ -999,7 +1001,7 @@ class _ShortReelItemState extends State<ShortReelItem> value: _progress, minHeight: 3, backgroundColor: Colors.white24, - color: const Color(0xFFE53935), + color: AppColors.primary, ), ), ); diff --git a/lib/features/shorts/presentation/widgets/shorts_state_views.dart b/lib/features/shorts/presentation/widgets/shorts_state_views.dart index 17721c9c..5f5c476d 100644 --- a/lib/features/shorts/presentation/widgets/shorts_state_views.dart +++ b/lib/features/shorts/presentation/widgets/shorts_state_views.dart @@ -20,7 +20,7 @@ class ShortsLoadingView extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.15), shape: BoxShape.circle, ), - child: const Padding( + child: Padding( padding: EdgeInsets.all(14), child: CircularProgressIndicator( color: AppColors.primary, diff --git a/lib/features/splash/presentation/widgets/netflix_splash.dart b/lib/features/splash/presentation/widgets/netflix_splash.dart index f142fcda..016e1bc8 100644 --- a/lib/features/splash/presentation/widgets/netflix_splash.dart +++ b/lib/features/splash/presentation/widgets/netflix_splash.dart @@ -106,7 +106,7 @@ class _NetflixSplashState extends State<NetflixSplash> child: Transform.scale( scale: _sScale.value, alignment: Alignment.center, - child: const Text('S', style: _kStyle), + child: Text('S', style: _kStyle), ), ), ClipRect( @@ -115,7 +115,7 @@ class _NetflixSplashState extends State<NetflixSplash> widthFactor: _oplayWidth.value, child: Opacity( opacity: _oplayOpacity.value, - child: const Text('OZO', style: _kStyle), + child: Text('OZO', style: _kStyle), ), ), ), @@ -129,7 +129,9 @@ class _NetflixSplashState extends State<NetflixSplash> } } -const TextStyle _kStyle = TextStyle( +/// A getter, not a `const`: the wordmark is painted in the accent colour, and +/// the accent is a runtime choice now. +TextStyle get _kStyle => TextStyle( color: AppColors.primary, fontSize: 80, fontWeight: FontWeight.w900, diff --git a/lib/features/streak/presentation/dialogs/streak_milestone_dialog.dart b/lib/features/streak/presentation/dialogs/streak_milestone_dialog.dart index ba719894..b55ef325 100644 --- a/lib/features/streak/presentation/dialogs/streak_milestone_dialog.dart +++ b/lib/features/streak/presentation/dialogs/streak_milestone_dialog.dart @@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; const Color _ember = Color(0xFFFFA94D); const Color _emberDeep = Color(0xFFEF7A35); @@ -97,10 +98,10 @@ class _StreakMilestoneDialogState extends State<StreakMilestoneDialog> constraints: const BoxConstraints(maxWidth: 360), padding: const EdgeInsets.fromLTRB(24, 30, 24, 22), decoration: BoxDecoration( - gradient: const LinearGradient( + gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF222222), Color(0xFF161616)], + colors: [AppColors.surface, AppColors.background], ), borderRadius: BorderRadius.circular(24), border: Border.all( @@ -158,7 +159,7 @@ class _StreakMilestoneDialogState extends State<StreakMilestoneDialog> ), foregroundColor: AppColors.textPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( @@ -178,7 +179,7 @@ class _StreakMilestoneDialogState extends State<StreakMilestoneDialog> padding: const EdgeInsets.symmetric(vertical: 13), backgroundColor: AppColors.primary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.ios_share_rounded, size: 16), @@ -374,7 +375,7 @@ class StreakFreezeSavedDialog extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 13), backgroundColor: _frostDeep, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( diff --git a/lib/features/streak/presentation/widgets/streak_card.dart b/lib/features/streak/presentation/widgets/streak_card.dart index 2e8c513b..fc0d9982 100644 --- a/lib/features/streak/presentation/widgets/streak_card.dart +++ b/lib/features/streak/presentation/widgets/streak_card.dart @@ -90,10 +90,13 @@ class _CompactStreakRow extends StatelessWidget { onTap: () => context.push('/streak'), child: Ink( decoration: BoxDecoration( - gradient: const LinearGradient( + // surface -> background, which is #242424 -> #181818 on the + // default theme: the same top-left-lighter fall the literals + // #222222 -> #1A1A1A gave, but it follows AMOLED. + gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF222222), Color(0xFF1A1A1A)], + colors: [AppColors.surface, AppColors.background], ), borderRadius: BorderRadius.circular(16), border: Border.all( diff --git a/lib/features/tracker/presentation/pages/following_page.dart b/lib/features/tracker/presentation/pages/following_page.dart index 3266ad16..088511d7 100644 --- a/lib/features/tracker/presentation/pages/following_page.dart +++ b/lib/features/tracker/presentation/pages/following_page.dart @@ -208,7 +208,7 @@ class _FollowedTitlesViewState extends State<FollowedTitlesView> child: Column( children: [ if (_checking) - const LinearProgressIndicator( + LinearProgressIndicator( minHeight: 2, backgroundColor: Colors.transparent, valueColor: AlwaysStoppedAnimation(AppColors.primary), diff --git a/lib/features/trivia/presentation/pages/actor_hero_page.dart b/lib/features/trivia/presentation/pages/actor_hero_page.dart index 65cca721..b7c62289 100644 --- a/lib/features/trivia/presentation/pages/actor_hero_page.dart +++ b/lib/features/trivia/presentation/pages/actor_hero_page.dart @@ -252,12 +252,12 @@ class _Backdrop extends StatelessWidget { fit: BoxFit.cover, alignment: Alignment.topCenter, placeholder: (_, _) => - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), errorWidget: (_, _, _) => - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), ) else - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -267,7 +267,7 @@ class _Backdrop extends StatelessWidget { ), ), ), - const Positioned( + Positioned( left: 0, right: 0, bottom: 0, @@ -278,12 +278,15 @@ class _Backdrop extends StatelessWidget { gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, + // Every stop is the page background at falling opacity, + // so the backdrop dissolves into whatever the page is — + // #181818 or true black. colors: [ AppColors.background, - Color(0xEE181818), - Color(0x00181818), + AppColors.background.withValues(alpha: 0.933), + AppColors.background.withValues(alpha: 0.0), ], - stops: [0.0, 0.5, 1.0], + stops: const [0.0, 0.5, 1.0], ), ), ), @@ -402,7 +405,7 @@ class _TitlesChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(CupertinoIcons.film_fill, + Icon(CupertinoIcons.film_fill, color: AppColors.primaryLight, size: 14), const SizedBox(width: 7), Text( @@ -771,7 +774,7 @@ class _SecondaryButton extends StatelessWidget { border: Border.all(color: AppColors.primary.withValues(alpha: 0.4)), ), child: busy - ? const SizedBox( + ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator( @@ -782,7 +785,7 @@ class _SecondaryButton extends StatelessWidget { : Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(CupertinoIcons.person_2_fill, + Icon(CupertinoIcons.person_2_fill, color: AppColors.primary, size: 16), const SizedBox(width: 7), Flexible( @@ -790,7 +793,7 @@ class _SecondaryButton extends StatelessWidget { 'trivia.challenge_friend'.tr(), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 14, fontWeight: FontWeight.w600, @@ -847,7 +850,7 @@ class _InlineError extends StatelessWidget { ), child: Text( 'general.retry'.tr(), - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 14, fontWeight: FontWeight.w600, diff --git a/lib/features/trivia/presentation/pages/buff_hub_page.dart b/lib/features/trivia/presentation/pages/buff_hub_page.dart index 90905587..4a509b3d 100644 --- a/lib/features/trivia/presentation/pages/buff_hub_page.dart +++ b/lib/features/trivia/presentation/pages/buff_hub_page.dart @@ -102,7 +102,7 @@ class _Masthead extends StatelessWidget { children: [ Row( children: [ - const Icon( + Icon( CupertinoIcons.film_fill, color: AppColors.primary, size: 24, @@ -292,7 +292,7 @@ class _FaceStack extends StatelessWidget { color: AppColors.surfaceVariant, borderRadius: BorderRadius.circular(12), ), - child: const Icon( + child: Icon( CupertinoIcons.heart_fill, color: AppColors.primaryLight, size: 24, @@ -314,7 +314,7 @@ class _FaceStack extends StatelessWidget { left: i * _step, child: Container( padding: const EdgeInsets.all(_ring), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, shape: BoxShape.circle, ), @@ -919,7 +919,7 @@ class _HowStep extends StatelessWidget { Container( width: 40, height: 40, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceVariant, shape: BoxShape.circle, ), @@ -1045,7 +1045,7 @@ class _RankRow extends StatelessWidget { ringWidth: 2, ) : Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceVariant, shape: BoxShape.circle, ), @@ -1123,7 +1123,7 @@ class _RankRow extends StatelessWidget { child: Text( 'trivia.points_value'.tr(args: ['${entry.score}']), maxLines: 1, - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontSize: 13, fontWeight: FontWeight.w800, diff --git a/lib/features/trivia/presentation/pages/challenge_landing_page.dart b/lib/features/trivia/presentation/pages/challenge_landing_page.dart index 4be20437..f3f2a36c 100644 --- a/lib/features/trivia/presentation/pages/challenge_landing_page.dart +++ b/lib/features/trivia/presentation/pages/challenge_landing_page.dart @@ -205,7 +205,7 @@ class _Backdrop extends StatelessWidget { imageUrl: url, fit: BoxFit.cover, errorWidget: (_, _, _) => - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), ), BackdropFilter( filter: ImageFilter.blur(sigmaX: 34, sigmaY: 34), @@ -238,7 +238,7 @@ class _SwordsBadge extends StatelessWidget { return Container( width: 88, height: 88, - decoration: const BoxDecoration( + decoration: BoxDecoration( shape: BoxShape.circle, color: AppColors.primary, ), @@ -330,7 +330,7 @@ class _MiniAvatar extends StatelessWidget { const size = 34.0; return Container( padding: const EdgeInsets.all(2), - decoration: const BoxDecoration( + decoration: BoxDecoration( shape: BoxShape.circle, color: AppColors.surface, ), @@ -409,7 +409,7 @@ class _LoadingView extends StatelessWidget { @override Widget build(BuildContext context) { - return const Center( + return Center( child: CircularProgressIndicator(color: AppColors.primary), ); } diff --git a/lib/features/trivia/presentation/pages/game_page.dart b/lib/features/trivia/presentation/pages/game_page.dart index c1a37324..21e5400c 100644 --- a/lib/features/trivia/presentation/pages/game_page.dart +++ b/lib/features/trivia/presentation/pages/game_page.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/player/media_controller.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/trivia/domain/entities/trivia_option_entity.dart'; @@ -181,7 +182,7 @@ class _GameViewState extends State<_GameView> { onPressed: () => Navigator.of(ctx).pop(true), child: Text( 'trivia.forfeit_confirm'.tr(), - style: const TextStyle(color: AppColors.primaryLight), + style: TextStyle(color: AppColors.primaryLight), ), ), ], @@ -435,7 +436,7 @@ class _RevealPanel extends StatelessWidget { border: Border.all( color: (reveal.correct ? AppColors.success - : AppColors.primary) + : AppColors.error) .withValues(alpha: 0.5), width: 1.2, ), @@ -463,7 +464,7 @@ class _RevealPanel extends StatelessWidget { style: TextStyle( color: reveal.correct ? AppColors.success - : AppColors.primaryLight, + : AppColors.errorLight, fontSize: 12, fontWeight: FontWeight.w800, height: 1.2, @@ -502,7 +503,7 @@ class _RevealPanel extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(CupertinoIcons.play_fill, size: 16), diff --git a/lib/features/trivia/presentation/pages/leaderboard_page.dart b/lib/features/trivia/presentation/pages/leaderboard_page.dart index 0258cf74..b44aa997 100644 --- a/lib/features/trivia/presentation/pages/leaderboard_page.dart +++ b/lib/features/trivia/presentation/pages/leaderboard_page.dart @@ -234,7 +234,7 @@ class _PinnedMyRow extends StatelessWidget { final bottomSafe = MediaQuery.paddingOf(context).bottom; return Container( padding: EdgeInsets.fromLTRB(16, 10, 16, 10 + bottomSafe), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.navBackground, border: Border(top: BorderSide(color: AppColors.border)), ), diff --git a/lib/features/trivia/presentation/pages/result_page.dart b/lib/features/trivia/presentation/pages/result_page.dart index 54f4a236..0519929e 100644 --- a/lib/features/trivia/presentation/pages/result_page.dart +++ b/lib/features/trivia/presentation/pages/result_page.dart @@ -10,6 +10,7 @@ import 'package:go_router/go_router.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/trivia/domain/entities/actor_ref_entity.dart'; import 'package:soplay/features/trivia/domain/entities/trivia_result_entity.dart'; import 'package:soplay/features/trivia/presentation/trivia_args.dart'; @@ -412,11 +413,11 @@ class _SecondaryButton extends StatelessWidget { side: BorderSide(color: Colors.white.withValues(alpha: 0.16)), foregroundColor: AppColors.textPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: busy - ? const SizedBox( + ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( diff --git a/lib/features/trivia/presentation/pages/top_fans_page.dart b/lib/features/trivia/presentation/pages/top_fans_page.dart index b7582114..9aa7406e 100644 --- a/lib/features/trivia/presentation/pages/top_fans_page.dart +++ b/lib/features/trivia/presentation/pages/top_fans_page.dart @@ -254,7 +254,7 @@ class _PinnedMyRow extends StatelessWidget { final bottomSafe = MediaQuery.paddingOf(context).bottom; return Container( padding: EdgeInsets.fromLTRB(16, 10, 16, 10 + bottomSafe), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.navBackground, border: Border(top: BorderSide(color: AppColors.border)), ), @@ -274,7 +274,7 @@ class _FandomBadge extends StatelessWidget { children: [ Text( '${percent.toStringAsFixed(0)}%', - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontSize: 17, fontWeight: FontWeight.w900, diff --git a/lib/features/trivia/presentation/widgets/buff_empty_panel.dart b/lib/features/trivia/presentation/widgets/buff_empty_panel.dart index cea7a99e..71ad9426 100644 --- a/lib/features/trivia/presentation/widgets/buff_empty_panel.dart +++ b/lib/features/trivia/presentation/widgets/buff_empty_panel.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; /// The app's shipped empty / error panel, lifted out of the cast picker so every /// Buff surface says "there is nothing here" in exactly one voice. @@ -84,7 +85,7 @@ class BuffEmptyPanel extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( diff --git a/lib/features/trivia/presentation/widgets/cast_card.dart b/lib/features/trivia/presentation/widgets/cast_card.dart index 76e4ac7f..071d8334 100644 --- a/lib/features/trivia/presentation/widgets/cast_card.dart +++ b/lib/features/trivia/presentation/widgets/cast_card.dart @@ -219,7 +219,7 @@ class _HighlightedName extends StatelessWidget { TextSpan(text: name.substring(0, start)), TextSpan( text: name.substring(start, end), - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontWeight: FontWeight.w800, ), diff --git a/lib/features/trivia/presentation/widgets/countdown_ring.dart b/lib/features/trivia/presentation/widgets/countdown_ring.dart index 0414caf7..1021b340 100644 --- a/lib/features/trivia/presentation/widgets/countdown_ring.dart +++ b/lib/features/trivia/presentation/widgets/countdown_ring.dart @@ -24,7 +24,9 @@ class CountdownRing extends StatelessWidget { @override Widget build(BuildContext context) { - final color = _danger ? AppColors.primaryLight : Colors.white; + // The last four seconds are an alarm state, so they stay red whatever the + // accent is — a green "hurry up" ring says the opposite of what it means. + final color = _danger ? AppColors.error : Colors.white; final target = totalSeconds <= 0 ? 0.0 : (secondsRemaining / totalSeconds).clamp(0.0, 1.0); diff --git a/lib/features/trivia/presentation/widgets/option_chip.dart b/lib/features/trivia/presentation/widgets/option_chip.dart index 8feffc66..527c6d2b 100644 --- a/lib/features/trivia/presentation/widgets/option_chip.dart +++ b/lib/features/trivia/presentation/widgets/option_chip.dart @@ -87,9 +87,14 @@ class OptionChip extends StatelessWidget { icon: Icons.check_rounded, ); case OptionChipStatus.wrong: + // error, not primary. These two chips are shown side by side and the + // whole point is that they read as opposites; under a green accent + // "wrong" and "correct" would both have been green. return _ChipPalette( - fill: AppColors.primary.withValues(alpha: 0.9), - border: AppColors.primaryLight, + fill: AppColors.error.withValues(alpha: 0.9), + // Lifted off the fill so the 1.4px rim still reads as a rim. This is + // the exact colour the chip had before the accent became a setting. + border: AppColors.errorLight, text: Colors.white, icon: Icons.close_rounded, ); diff --git a/lib/features/trivia/presentation/widgets/share_card.dart b/lib/features/trivia/presentation/widgets/share_card.dart index bd7abf02..4c1ecb47 100644 --- a/lib/features/trivia/presentation/widgets/share_card.dart +++ b/lib/features/trivia/presentation/widgets/share_card.dart @@ -54,7 +54,7 @@ class ShareCard extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.movie_filter_rounded, + Icon(Icons.movie_filter_rounded, color: AppColors.primary, size: 22), const SizedBox(width: 8), Text( diff --git a/lib/features/user_lists/presentation/pages/user_lists_page.dart b/lib/features/user_lists/presentation/pages/user_lists_page.dart index 49e06c41..b0335ebd 100644 --- a/lib/features/user_lists/presentation/pages/user_lists_page.dart +++ b/lib/features/user_lists/presentation/pages/user_lists_page.dart @@ -132,7 +132,7 @@ class _UserListTabState extends State<_UserListTab> super.build(context); final items = _items; if (items == null) { - return const Center( + return Center( child: CircularProgressIndicator(color: AppColors.primary), ); } @@ -148,7 +148,7 @@ class _UserListTabState extends State<_UserListTab> physics: const AlwaysScrollableScrollPhysics(), itemCount: items.length, separatorBuilder: (_, _) => - const Divider(color: AppColors.divider, height: 1, indent: 84), + Divider(color: AppColors.divider, height: 1, indent: 84), itemBuilder: (context, i) { final item = items[i]; return _UserListRow( diff --git a/lib/features/watch_party/presentation/pages/watch_party_page.dart b/lib/features/watch_party/presentation/pages/watch_party_page.dart index 58f77cce..3ebb82ed 100644 --- a/lib/features/watch_party/presentation/pages/watch_party_page.dart +++ b/lib/features/watch_party/presentation/pages/watch_party_page.dart @@ -367,14 +367,14 @@ class _WatchPartyPageState extends State<WatchPartyPage> { ), ), ), - const Divider(height: 1, color: AppColors.border), + Divider(height: 1, color: AppColors.border), // Fixed reaction bar directly above the chat — always reachable instead // of buried at the bottom of the scrollable top region. Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Center(child: PartyReactionPicker(service: _service)), ), - const Divider(height: 1, color: AppColors.border), + Divider(height: 1, color: AppColors.border), Expanded( flex: 6, child: Stack( @@ -407,7 +407,7 @@ class _ConnectingView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox( + SizedBox( width: 34, height: 34, child: CircularProgressIndicator( diff --git a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart index 056c5ba8..e16c32f0 100644 --- a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart +++ b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart @@ -198,7 +198,7 @@ class _Composer extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, border: Border( top: BorderSide(color: AppColors.divider, width: 0.6), diff --git a/lib/features/watch_party/presentation/widgets/party_code_sheet.dart b/lib/features/watch_party/presentation/widgets/party_code_sheet.dart index 85bb5555..94371dfc 100644 --- a/lib/features/watch_party/presentation/widgets/party_code_sheet.dart +++ b/lib/features/watch_party/presentation/widgets/party_code_sheet.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/watch_party/data/watch_party_service.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; @@ -102,7 +103,7 @@ class _PartyCreateSheetState extends State<PartyCreateSheet> { ), const SizedBox(height: 20), if (_loading) - const Padding( + Padding( padding: EdgeInsets.symmetric(vertical: 28), child: Center( child: SizedBox( @@ -207,7 +208,7 @@ class _CodeReveal extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.groups_rounded, size: 18), @@ -358,7 +359,7 @@ class _PartyJoinSheetState extends State<PartyJoinSheet> { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( @@ -459,7 +460,7 @@ class _ErrorRetry extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text('general.try_again'.tr()), diff --git a/lib/features/watch_party/presentation/widgets/party_error_views.dart b/lib/features/watch_party/presentation/widgets/party_error_views.dart index ec56e108..5ec005cb 100644 --- a/lib/features/watch_party/presentation/widgets/party_error_views.dart +++ b/lib/features/watch_party/presentation/widgets/party_error_views.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; /// Full-screen glass card for a party terminal / error state. Mirrors the visual /// idiom of `my_list_state_views.dart`. @@ -98,7 +99,7 @@ class PartyStateView extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text(actionLabel!), diff --git a/lib/features/watch_party/presentation/widgets/party_member_bar.dart b/lib/features/watch_party/presentation/widgets/party_member_bar.dart index ca9abacb..81b19bbb 100644 --- a/lib/features/watch_party/presentation/widgets/party_member_bar.dart +++ b/lib/features/watch_party/presentation/widgets/party_member_bar.dart @@ -104,7 +104,7 @@ class _MemberTile extends StatelessWidget { left: -2, child: Container( padding: const EdgeInsets.all(2), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, shape: BoxShape.circle, ), diff --git a/test/core/theme/app_palette_test.dart b/test/core/theme/app_palette_test.dart new file mode 100644 index 00000000..a746a55d --- /dev/null +++ b/test/core/theme/app_palette_test.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/theme/app_accent.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_palette.dart'; + +/// The Appearance feature made ~1900 colour reads across the app dynamic. The +/// one thing that must never regress is that the *default* is still exactly the +/// palette the app shipped with — an install that never opens Appearance has to +/// be pixel-identical to the previous build. +void main() { + setUp(() { + AppPalette.current = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + }); + + group('default palette is byte-identical to the shipped constants', () { + test('accent triple', () { + expect(AppColors.primary, const Color(0xFFE50914)); + expect(AppColors.primaryDark, const Color(0xFFB20710)); + expect(AppColors.primaryLight, const Color(0xFFFF4B55)); + }); + + test('neutral ramp', () { + expect(AppColors.background, const Color(0xFF181818)); + expect(AppColors.navBackground, const Color(0xFF0F0F0F)); + expect(AppColors.surface, const Color(0xFF242424)); + expect(AppColors.card, const Color(0xFF282828)); + expect(AppColors.surfaceVariant, const Color(0xFF303030)); + expect(AppColors.border, const Color(0xFF3A3A3A)); + expect(AppColors.divider, const Color(0xFF2A2A2A)); + }); + + test('fixed roles are untouched by the accent', () { + expect(AppColors.error, const Color(0xFFE50914)); + // The exact old primaryLight — the "wrong answer" / "failed" states used + // to borrow it, and must render identically now they point at error. + expect(AppColors.errorLight, const Color(0xFFFF4B55)); + expect(AppColors.success, const Color(0xFF46D369)); + expect(AppColors.rating, const Color(0xFFFFD700)); + expect(AppColors.splashBackground, const Color(0xFF000000)); + + AppPalette.current = AppPalette.resolve( + accent: AppAccent.presets.firstWhere((a) => a.id == 'ocean'), + darkness: AppDarkness.dark, + ); + // Error is deliberately NOT the accent: a destructive action must still + // read as destructive under a blue theme. + expect(AppColors.error, const Color(0xFFE50914)); + expect(AppColors.errorLight, const Color(0xFFFF4B55)); + expect(AppColors.primary, isNot(AppColors.error)); + expect(AppColors.primaryLight, isNot(AppColors.errorLight)); + }); + + test('hero gradient reproduces the old literal stops', () { + // Was hard-coded as [#1E1416, #181818, #101010] in profile_page.dart. + final top = AppColors.heroTop; + expect(top.r * 255, closeTo(0x1E, 1.0)); + expect(top.g * 255, closeTo(0x14, 1.0)); + expect(top.b * 255, closeTo(0x16, 2.0)); + expect(AppColors.heroMid, const Color(0xFF181818)); + expect(AppColors.heroBottom, const Color(0xFF101010)); + }); + }); + + group('AMOLED', () { + setUp(() { + AppPalette.current = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.black, + ); + }); + + test('background and nav go to true black', () { + expect(AppColors.background, const Color(0xFF000000)); + expect(AppColors.navBackground, const Color(0xFF000000)); + expect(AppColors.isBlack, isTrue); + }); + + test('the depth ordering of the ramp survives', () { + double l(Color c) => c.computeLuminance(); + expect(l(AppColors.background), lessThanOrEqualTo(l(AppColors.surface))); + expect(l(AppColors.surface), lessThan(l(AppColors.card))); + expect(l(AppColors.card), lessThan(l(AppColors.surfaceVariant))); + // Hairlines have to stay clearly above the surface they sit on, or every + // card edge disappears into the black. + expect(l(AppColors.border), greaterThan(l(AppColors.surfaceVariant))); + expect(l(AppColors.divider), greaterThan(l(AppColors.surface))); + }); + + test('the accent is untouched by the darkness level', () { + expect(AppColors.primary, const Color(0xFFE50914)); + }); + + test('hero gradient bottoms out at true black', () { + expect(AppColors.heroBottom, const Color(0xFF000000)); + expect(AppColors.heroMid, const Color(0xFF000000)); + }); + }); + + group('accent legibility contract', () { + test('every preset keeps white readable on it', () { + for (final accent in AppAccent.presets) { + expect( + AppAccent.whiteContrast(accent.base), + greaterThanOrEqualTo(AppAccent.minWhiteContrast), + reason: '${accent.id} would make white text unreadable on a fill', + ); + } + }); + + test('every preset satisfies the two-sided contract', () { + for (final accent in AppAccent.presets) { + expect( + AppAccent.isLegibleAccent(accent.base), + isTrue, + reason: '${accent.id} is outside the legible band', + ); + } + }); + + test('every preset stays visible against both backgrounds', () { + double ratio(Color a, Color b) { + final la = a.computeLuminance(); + final lb = b.computeLuminance(); + final hi = la > lb ? la : lb; + final lo = la > lb ? lb : la; + return (hi + 0.05) / (lo + 0.05); + } + + for (final accent in AppAccent.presets) { + // 3.6 is just under the shipped red's own 3.70 against #181818, so no + // accent is ever less legible than the one the app always had. + expect( + ratio(accent.base, const Color(0xFF181818)), + greaterThan(3.6), + reason: '${accent.id} disappears into the dark background', + ); + expect( + ratio(accent.base, const Color(0xFF000000)), + greaterThan(3.6), + reason: '${accent.id} disappears into the AMOLED background', + ); + } + }); + + test('preset ids are unique and stable', () { + final ids = AppAccent.presets.map((a) => a.id).toList(); + expect(ids.toSet().length, ids.length); + expect(ids.first, 'red', reason: 'the default must stay first'); + expect(ids, isNot(contains(AppAccent.customId))); + }); + }); + + group('custom accents', () { + test('a legible seed is kept as-is', () { + const seed = Color(0xFF2F7BF6); + expect(AppAccent.custom(seed).base, seed); + }); + + test('a too-bright seed is darkened until white works on it', () { + // Pure yellow: 1.07:1 against white — unusable as a button fill. + final accent = AppAccent.custom(const Color(0xFFFFFF00)); + expect( + AppAccent.whiteContrast(accent.base), + greaterThanOrEqualTo(AppAccent.minWhiteContrast), + ); + // ...but it is still recognisably yellow, not brown mush. + expect( + HSLColor.fromColor(accent.base).hue, + closeTo(60, 1.0), + ); + }); + + test('a too-dark seed is lightened until it shows on the page', () { + // Near-black would pass the white rule perfectly and then be invisible on + // the page — most visibly on the splash, which is nothing but the accent. + final accent = AppAccent.custom(const Color(0xFF060606)); + expect( + AppAccent.blackContrast(accent.base), + greaterThanOrEqualTo(AppAccent.minBackgroundContrast), + ); + expect( + AppAccent.whiteContrast(accent.base), + greaterThanOrEqualTo(AppAccent.minWhiteContrast), + ); + }); + + test('white and black seeds both terminate inside the legible band', () { + for (final seed in const [ + Color(0xFFFFFFFF), + Color(0xFF000000), + Color(0xFF7F7F7F), + Color(0xFF00FF00), + Color(0xFF0000FF), + ]) { + final base = AppAccent.custom(seed).base; + expect( + AppAccent.isLegibleAccent(base), + isTrue, + reason: 'seed $seed landed outside the legible band at $base', + ); + } + }); + + test('a translucent seed is forced opaque', () { + expect(AppAccent.custom(const Color(0x402F7BF6)).base.a, 1.0); + }); + + test('derivation matches the shipped red family rule exactly', () { + // The dark/light derivation is calibrated so that the default red + // reproduces its own hand-tuned variants. + final derived = AppAccent.custom(const Color(0xFFE50914)); + expect(derived.dark, const Color(0xFFB20710)); + expect(derived.light.r * 255, closeTo(0xFF, 1.0)); + expect(derived.light.g * 255, closeTo(0x4B, 1.0)); + expect(derived.light.b * 255, closeTo(0x55, 1.0)); + }); + + test('byId rejects unknown and custom ids', () { + expect(AppAccent.byId('nope'), isNull); + expect(AppAccent.byId(''), isNull); + expect(AppAccent.byId(null), isNull); + expect(AppAccent.byId(AppAccent.customId), isNull); + expect(AppAccent.byId('violet')?.id, 'violet'); + }); + }); + + group('tab-bar tint', () { + test('resolve() honours what it was asked for, not a policy', () { + // The stored preference ships ON; the pure resolver still defaults off, + // so a hand-built palette is exactly what the caller described. + expect(AppPalette.current.tintNav, isFalse); + expect(AppColors.isNavTinted, isFalse); + }); + + test('rides the same palette every other colour does', () { + AppPalette.current = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + tintNav: true, + ); + expect(AppColors.isNavTinted, isTrue); + // ...and changes nothing else. + expect(AppColors.primary, const Color(0xFFE50914)); + expect(AppColors.background, const Color(0xFF181818)); + }); + + test('counts as a difference, so the tree repaints when it flips', () { + final off = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + final on = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + tintNav: true, + ); + expect(off, isNot(on)); + expect(off.hashCode, isNot(on.hashCode)); + }); + }); + + test('palette equality is by accent and darkness', () { + final a = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + final b = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + final c = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.black, + ); + expect(a, b); + expect(a, isNot(c)); + }); +} diff --git a/test/features/profile/theme_preview_test.dart b/test/features/profile/theme_preview_test.dart new file mode 100644 index 00000000..26ffdd7e --- /dev/null +++ b/test/features/profile/theme_preview_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/theme/app_accent.dart'; +import 'package:soplay/core/theme/app_palette.dart'; +import 'package:soplay/features/profile/presentation/widgets/theme_preview.dart'; + +/// [ThemePreview] is laid out against a hand-computed 240 × 480 canvas — the +/// column of fixed heights has to keep adding up to less than that. These pump +/// it at every accent, both darkness levels, and both the large and thumbnail +/// sizes, so a future tweak to one child's height cannot silently start +/// overflowing. +void main() { + Future<void> pumpAt(WidgetTester tester, Widget child, Size size) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox(width: size.width, height: size.height, child: child), + ), + ), + ), + ); + } + + testWidgets('renders at the large size for every accent and darkness', + (tester) async { + for (final accent in AppAccent.presets) { + for (final darkness in AppDarkness.values) { + await pumpAt( + tester, + ThemePreview( + palette: AppPalette.resolve(accent: accent, darkness: darkness), + ), + const Size(240, 480), + ); + expect( + tester.takeException(), + isNull, + reason: '${accent.id} / ${darkness.name} overflowed', + ); + } + } + }); + + testWidgets('renders as a thumbnail without chrome', (tester) async { + // Roughly the width one darkness tile gives it on a small phone. + await pumpAt( + tester, + ThemePreview( + palette: AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.black, + ), + showChrome: false, + ), + const Size(75, 150), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('survives a very narrow box', (tester) async { + await pumpAt( + tester, + ThemePreview( + palette: AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ), + showChrome: false, + ), + const Size(40, 80), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('a custom accent renders too', (tester) async { + await pumpAt( + tester, + ThemePreview( + palette: AppPalette.resolve( + accent: AppAccent.custom(const Color(0xFFFFFF00)), + darkness: AppDarkness.black, + ), + ), + const Size(240, 480), + ); + expect(tester.takeException(), isNull); + }); +}