From 5efb0bb56c55fc0542aab2ffcc87527790e58a81 Mon Sep 17 00:00:00 2001 From: azamov Date: Mon, 24 Aug 2026 09:51:21 +0500 Subject: [PATCH 1/7] feat(mal): integrate MyAnimeList tracking and enhance episode reporting --- .../com/soplay/sozo/cloudstream/PluginHost.kt | 75 +- assets/translations/en.json | 48 +- assets/translations/ru.json | 48 +- assets/translations/uz.json | 48 +- lib/app.dart | 60 + lib/core/constants/app_constants.dart | 17 + lib/core/deeplink/deeplink_opt_in.dart | 5 +- lib/core/di/injection.dart | 8 + lib/core/js/dart_fetch.dart | 21 +- lib/core/js/js_runtime_service.dart | 13 +- lib/core/js/js_timeouts.dart | 7 +- lib/core/network/cf_bypass_service.dart | 38 +- lib/core/router/app_router.dart | 5 + lib/core/storage/hive_service.dart | 32 + .../subtitle_translation_service.dart | 70 + lib/core/theme/app_accent.dart | 274 ++++ lib/core/theme/app_colors.dart | 91 +- lib/core/theme/app_palette.dart | 182 +++ lib/core/theme/app_theme.dart | 93 +- lib/core/theme/theme_controller.dart | 150 ++- lib/core/widgets/app_buttons.dart | 129 ++ lib/core/widgets/app_tab_bar.dart | 9 +- lib/features/anilist/data/anilist_api.dart | 59 +- .../pages/airing_calendar_page.dart | 4 +- .../pages/anilist_browse_page.dart | 3 +- .../pages/anilist_library_page.dart | 2 +- .../presentation/pages/connections_page.dart | 7 +- .../presentation/widgets/anilist_brand.dart | 5 +- .../widgets/anilist_entry_sheet.dart | 4 +- .../widgets/anilist_link_sheet.dart | 2 +- .../pages/app_lock_settings_page.dart | 6 +- .../presentation/pages/pin_verify_page.dart | 6 +- .../presentation/pages/force_update_page.dart | 3 +- .../widgets/release_notes_view.dart | 2 +- .../presentation/pages/otp_verify_page.dart | 2 +- .../presentation/widgets/auth_widgets.dart | 52 +- .../widgets/banners_carousel.dart | 2 +- .../cloudflare/cloudflare_solver_page.dart | 5 +- .../pages/cloudstream_plugins_page.dart | 4 +- .../pages/cloudstream_sources_page.dart | 4 +- .../presentation/widgets/comments_panel.dart | 9 +- .../pages/desktop_share_page.dart | 4 +- .../detail/presentation/pages/actor_page.dart | 21 +- .../presentation/pages/detail_page.dart | 4 +- .../presentation/pages/episodes_page.dart | 12 +- .../pages/player_page.subtitles.dart | 170 ++- .../pages/player_page.widgets.dart | 6 +- .../presentation/widgets/detail_hero.dart | 12 +- .../presentation/widgets/detail_info.dart | 7 +- .../widgets/detail_more_menu.dart | 8 +- .../presentation/widgets/detail_related.dart | 2 +- .../widgets/detail_screenshots.dart | 2 +- .../widgets/player_engine_sheet.dart | 3 +- .../presentation/pages/downloads_page.dart | 20 +- .../presentation/repo_file_import.dart | 4 +- .../presentation/pages/history_page.dart | 18 +- .../home/presentation/widgets/genre_card.dart | 14 +- .../presentation/widgets/home_banner.dart | 45 +- .../presentation/widgets/home_content.dart | 3 +- .../widgets/home_history_section.dart | 6 +- .../widgets/home_movie_section.dart | 35 +- .../widgets/home_state_views.dart | 2 +- .../presentation/widgets/home_top_bar.dart | 190 +-- .../widgets/view_all_widgets.dart | 2 +- .../presentation/pages/link_tv_page.dart | 4 +- .../presentation/pages/live_tv_page.dart | 4 +- .../main/presentation/pages/main_page.dart | 98 +- .../presentation/widgets/mal_entry_sheet.dart | 7 +- .../presentation/widgets/mal_link_sheet.dart | 2 +- .../pages/manga_source_settings_page.dart | 10 +- .../pages/manga_sources_page.dart | 8 +- .../manga/presentation/pages/reader_page.dart | 13 +- .../widgets/my_list_background.dart | 10 +- .../presentation/pages/no_internet_page.dart | 4 +- .../pages/notifications_page.dart | 16 +- .../presentation/pages/onboarding_page.dart | 15 +- .../presentation/widgets/anime_ribbons.dart | 2 +- .../presentation/widgets/tv_showcase.dart | 10 +- .../presentation/pages/private_list_page.dart | 2 +- .../pages/private_unlock_page.dart | 2 +- .../presentation/pages/appearance_page.dart | 1146 +++++++++++++++++ .../pages/player_settings_page.dart | 2 +- .../presentation/pages/profile_edit_page.dart | 2 +- .../presentation/pages/profile_page.dart | 435 +++++-- .../presentation/pages/providers_page.dart | 2 +- .../presentation/widgets/library_accents.dart | 121 ++ .../presentation/widgets/settings_tiles.dart | 4 +- .../widgets/tab_customizer_sheet.dart | 5 +- .../presentation/widgets/theme_preview.dart | 381 ++++++ .../presentation/pages/tv_remote_page.dart | 2 +- .../presentation/widgets/report_sheet.dart | 3 +- .../presentation/pages/cross_search_page.dart | 4 +- .../widgets/search_filter_sheet.dart | 5 +- .../presentation/widgets/search_header.dart | 4 +- .../widgets/search_set_sheet.dart | 2 +- .../widgets/search_state_views.dart | 6 +- .../presentation/pages/shorts_page.dart | 2 +- .../presentation/widgets/short_reel_item.dart | 12 +- .../widgets/shorts_state_views.dart | 2 +- .../presentation/widgets/netflix_splash.dart | 8 +- .../dialogs/streak_milestone_dialog.dart | 11 +- .../presentation/widgets/streak_card.dart | 7 +- .../presentation/pages/following_page.dart | 2 +- .../presentation/pages/actor_hero_page.dart | 27 +- .../presentation/pages/buff_hub_page.dart | 12 +- .../pages/challenge_landing_page.dart | 8 +- .../trivia/presentation/pages/game_page.dart | 9 +- .../presentation/pages/leaderboard_page.dart | 2 +- .../presentation/pages/result_page.dart | 5 +- .../presentation/pages/top_fans_page.dart | 4 +- .../widgets/buff_empty_panel.dart | 3 +- .../presentation/widgets/cast_card.dart | 2 +- .../presentation/widgets/countdown_ring.dart | 4 +- .../presentation/widgets/option_chip.dart | 9 +- .../presentation/widgets/share_card.dart | 2 +- .../presentation/pages/user_lists_page.dart | 4 +- .../presentation/pages/watch_party_page.dart | 6 +- .../widgets/party_chat_panel.dart | 2 +- .../widgets/party_code_sheet.dart | 9 +- .../widgets/party_error_views.dart | 3 +- .../widgets/party_member_bar.dart | 2 +- test/core/theme/app_palette_test.dart | 282 ++++ test/features/profile/theme_preview_test.dart | 91 ++ 123 files changed, 4487 insertions(+), 568 deletions(-) create mode 100644 lib/core/theme/app_accent.dart create mode 100644 lib/core/theme/app_palette.dart create mode 100644 lib/core/widgets/app_buttons.dart create mode 100644 lib/features/profile/presentation/pages/appearance_page.dart create mode 100644 lib/features/profile/presentation/widgets/library_accents.dart create mode 100644 lib/features/profile/presentation/widgets/theme_preview.dart create mode 100644 test/core/theme/app_palette_test.dart create mode 100644 test/features/profile/theme_preview_test.dart 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..c98bdb4b 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...", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 91776ebd..68d2c73b 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": "Поиск фильмов, сериалов...", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index ee6ed8bc..73570cf8 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...", 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/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.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/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..58c01df5 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'; @@ -472,7 +473,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_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/presentation/pages/live_tv_page.dart b/lib/features/live_tv/presentation/pages/live_tv_page.dart index 2c6653fc..e4d10187 100644 --- a/lib/features/live_tv/presentation/pages/live_tv_page.dart +++ b/lib/features/live_tv/presentation/pages/live_tv_page.dart @@ -597,7 +597,7 @@ class _LivePill extends StatelessWidget { Container( width: 5, height: 5, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -842,7 +842,7 @@ class _ChannelCard extends StatelessWidget { ), ), if (favourite) - const Positioned( + Positioned( top: 6, right: 6, child: Icon( 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); + }); +} From ebe95b13df7d19387a851c745b1910b8f2d94602 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 12:11:10 +0500 Subject: [PATCH 2/7] feat(mal): integrate MyAnimeList tracking and enhance episode reporting --- .../auth/presentation/bloc/auth_bloc.dart | 8 +++++ .../history/data/history_service.dart | 11 ++++++ .../history/data/history_sync_service.dart | 35 +++++++++++++++++++ 3 files changed, 54 insertions(+) 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<AuthEvent, AuthState> { /// tests where the sync service is not wired. Future<void> _syncHistory() async { if (!getIt.isRegistered<HistorySyncService>()) 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<HistorySyncService>().adoptFor(userId); + } await getIt<HistorySyncService>().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/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 { From 06be73020f298cd4bc41394e9211c9a7635aaad8 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 12:31:12 +0500 Subject: [PATCH 3/7] Make the player treat a broadcast as a broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live TV already told the player what it was handing over — `type: 'live'` — and the player never read it. Whether a stream was live was guessed from its duration alone, and plenty of live HLS carries a DVR window and reports a perfectly finite one. Those channels were treated as files: a scrub bar over something unscrubbable, frame-preview extraction against an endless stream, and the live edge read as the end of the video. So the declared type decides, and it is known before the first frame rather than after it, because a channel that is down when you open it fails during initialize() and that is exactly the path that needs to know. Reconnection is the other half. A film either plays or is broken, so four retries a session is generous; a channel drops because a segment was late or a CDN failed over, and the only right answer is to open it again. Four in a two-hour evening meant the player gave up for good on something that was working seconds later. A broadcast now reconnects with a backoff, on errors a file would call fatal — a 403 on a live edge is usually a rotated token, and the next playlist fetch has the current one. Seeking is refused outright. The double-tap gesture reached the same seek as the scrub bar, and a tap landing outside the DVR window stalls the stream with no way back but leaving the channel. Channels also carry the headers their origin insists on, all the way to the player and into the pinned-card cache — a header-gated channel used to work once and 403 forever after being favourited. --- assets/translations/en.json | 1 + assets/translations/ru.json | 1 + assets/translations/uz.json | 1 + .../pages/player_page.controls.dart | 8 + .../presentation/pages/player_page.dart | 27 ++ .../presentation/pages/player_page.media.dart | 74 +++++- .../live_tv/data/live_tv_service.dart | 89 +++++-- .../presentation/pages/live_tv_page.dart | 232 ++++++++++++++++-- 8 files changed, 386 insertions(+), 47 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index c98bdb4b..6db9aee8 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1217,6 +1217,7 @@ "retry": "Try again", "recent": "RECENTLY WATCHED", "folders": "FOLDERS", + "countries": "COUNTRIES", "search_in": "Search in {folder}…", "channel_count": { "one": "{} channel", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 68d2c73b..c5cdbc92 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1217,6 +1217,7 @@ "retry": "Повторить", "recent": "НЕДАВНО СМОТРЕЛИ", "folders": "ПАПКИ", + "countries": "СТРАНЫ", "search_in": "Поиск в {folder}…", "channel_count": { "one": "{} канал", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 73570cf8..850210ef 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1217,6 +1217,7 @@ "retry": "Qayta urinish", "recent": "YAQINDA KO‘RILGAN", "folders": "PAPKALAR", + "countries": "DAVLATLAR", "search_in": "{folder} ichida qidirish…", "channel_count": { "one": "{} kanal", 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<PlayerPage> Map<String, String> _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<VideoSourceEntity> _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<void> _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<void>.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<void> _autoRetry() async { if (!mounted) return; diff --git a/lib/features/live_tv/data/live_tv_service.dart b/lib/features/live_tv/data/live_tv_service.dart index dc072f09..bf5edac5 100644 --- a/lib/features/live_tv/data/live_tv_service.dart +++ b/lib/features/live_tv/data/live_tv_service.dart @@ -12,6 +12,7 @@ class LiveChannel { this.country = '', this.language = '', this.category = '', + this.headers = const {}, }); final String id; @@ -21,6 +22,14 @@ 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; @@ -33,6 +42,7 @@ class LiveChannel { country: country, language: language, category: value, + headers: headers, ); static LiveChannel? fromJson(Map<String, dynamic> json) { @@ -54,6 +64,13 @@ class LiveChannel { // Present when the channel arrives from a flat listing rather than from // inside a group; withCategory still fills it in for the grouped shape. 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 {}, + }, ); } } @@ -76,6 +93,30 @@ 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; + + static const empty = LiveIndex(folders: [], countries: []); +} + /// One page of channels. @immutable class LivePage { @@ -109,22 +150,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 +191,7 @@ class LiveTvService { /// megabytes with extra steps. Future<LivePage> browse({ String? category, + String? country, String? search, int page = 1, int limit = 40, @@ -144,6 +202,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(), }, ); 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 e4d10187..f0da821c 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'; @@ -34,6 +35,7 @@ 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 []; @@ -41,6 +43,11 @@ class _LiveTvPageState extends State<LiveTvPage> { /// 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; @@ -72,7 +79,11 @@ class _LiveTvPageState extends State<LiveTvPage> { } /// True while the screen is showing folders rather than channels. - bool get _atTopLevel => _folder.isEmpty && _query.trim().isEmpty; + bool get _atTopLevel => + _folder.isEmpty && _country.isEmpty && _query.trim().isEmpty; + + /// What the crumb says you are inside of. + String get _openName => _folder.isNotEmpty ? _folder : _countryName(_country); Future<void> _loadFolders() async { setState(() { @@ -80,11 +91,12 @@ class _LiveTvPageState extends State<LiveTvPage> { _error = null; }); try { - final folders = await _service.folders(); + final index = await _service.index(); if (!mounted) return; setState(() { - _folders = folders; - _total = folders.fold(0, (n, f) => n + f.count); + _folders = index.folders; + _countries = index.countries; + _total = index.folders.fold(0, (n, f) => n + f.count); _loading = false; }); } catch (_) { @@ -111,6 +123,7 @@ 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, ); @@ -160,6 +173,17 @@ class _LiveTvPageState extends State<LiveTvPage> { void _openFolder(String name) { setState(() { _folder = name; + _country = ''; + _channels = const []; + }); + if (_scroll.hasClients) _scroll.jumpTo(0); + _loadPage(); + } + + void _openCountry(String code) { + setState(() { + _country = code; + _folder = ''; _channels = const []; }); if (_scroll.hasClients) _scroll.jumpTo(0); @@ -169,6 +193,7 @@ class _LiveTvPageState extends State<LiveTvPage> { void _closeFolder() { setState(() { _folder = ''; + _country = ''; _channels = const []; _query = ''; }); @@ -183,12 +208,7 @@ class _LiveTvPageState extends State<LiveTvPage> { 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; @@ -199,18 +219,91 @@ class _LiveTvPageState extends State<LiveTvPage> { if (changed) getIt<HiveService>().setLiveTvCards(_cards); } + /// A country code as something readable. + /// + /// Only the ones this line-up actually carries are named; anything else keeps + /// its code, which is still better than an empty chip. Home comes first + /// wherever these are sorted, because it is what most people came for. + static const Map<String, String> _countryNames = { + 'UZ': 'O\'zbekiston', + 'RU': 'Rossiya', + 'TR': 'Turkiya', + 'KZ': 'Qozog\'iston', + 'KG': 'Qirg\'iziston', + 'TJ': 'Tojikiston', + 'TM': 'Turkmaniston', + 'AZ': 'Ozarbayjon', + 'US': 'AQSH', + 'UK': 'Buyuk Britaniya', + 'GB': 'Buyuk Britaniya', + 'CA': 'Kanada', + 'DE': 'Germaniya', + 'FR': 'Fransiya', + 'QA': 'Qatar', + 'AE': 'BAA', + 'CN': 'Xitoy', + 'JP': 'Yaponiya', + 'KR': 'Koreya', + 'SG': 'Singapur', + 'IL': 'Isroil', + 'AT': 'Avstriya', + 'SK': 'Slovakiya', + }; + + 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 — + /// every platform this runs on renders these. + 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,12 +311,7 @@ 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); } }); final hive = getIt<HiveService>(); @@ -234,12 +322,7 @@ 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)] @@ -251,7 +334,10 @@ class _LiveTvPageState extends State<LiveTvPage> { 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 @@ -329,9 +415,9 @@ class _LiveTvPageState extends State<LiveTvPage> { textInputAction: TextInputAction.search, decoration: InputDecoration( isDense: true, - hintText: _folder.isEmpty + hintText: _atTopLevel ? 'live_tv.search_hint'.tr() - : 'live_tv.search_in'.tr(namedArgs: {'folder': _folder}), + : 'live_tv.search_in'.tr(namedArgs: {'folder': _openName}), prefixIcon: const Icon(Icons.search_rounded, size: 20), suffixIcon: _query.isEmpty ? null @@ -348,10 +434,10 @@ class _LiveTvPageState extends State<LiveTvPage> { ), // Which folder is open, and the way back out of it. - if (_folder.isNotEmpty) + if (_folder.isNotEmpty || _country.isNotEmpty) SliverToBoxAdapter( child: _FolderCrumb( - folder: _folder, + folder: _openName, total: _total, onBack: _closeFolder, ), @@ -376,6 +462,15 @@ class _LiveTvPageState extends State<LiveTvPage> { // folders, and the one somebody wants is two taps away rather than // twenty megabytes and a scroll. _FolderGrid(folders: _folders, onOpen: _openFolder), + if (_countries.isNotEmpty) ...[ + _Header(label: 'live_tv.countries'.tr()), + _CountryRow( + countries: _countries, + nameOf: _countryName, + flagOf: _flagOf, + onOpen: _openCountry, + ), + ], ] else if (_loading) ...[ const SliverToBoxAdapter( child: Padding( @@ -944,3 +1039,86 @@ class _Empty extends StatelessWidget { ); } } + +/// The countries the line-up covers, as a strip you scroll sideways. +/// +/// A row rather than a grid, and below the folders: it is a shortcut for +/// somebody who already knows what they are after, not the main navigation. +/// Ordered by how much each country actually contributes — the line-up leads +/// with channels people recognise, and pinning a home country in front of that +/// puts a provincial station where a famous one should be. +class _CountryRow extends StatelessWidget { + const _CountryRow({ + required this.countries, + required this.nameOf, + required this.flagOf, + required this.onOpen, + }); + + final List<LiveCountry> countries; + final String Function(String) nameOf; + final String Function(String) flagOf; + final ValueChanged<String> onOpen; + + @override + Widget build(BuildContext context) { + final ordered = [...countries]..sort((a, b) => b.count.compareTo(a.count)); + + return SliverToBoxAdapter( + child: SizedBox( + height: 40, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 14), + itemCount: ordered.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final country = ordered[i]; + final flag = flagOf(country.code); + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onOpen(country.code), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 13), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (flag.isNotEmpty) ...[ + Text(flag, style: const TextStyle(fontSize: 15)), + const SizedBox(width: 7), + ], + Text( + nameOf(country.code), + style: const TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 6), + Text( + '${country.count}', + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} From 6f6163799598e546933fcbaea7b6af4c405cddef Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 12:48:45 +0500 Subject: [PATCH 4/7] Put Live TV where somebody might find it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was reachable only through Profile, seven rows down a settings list. That is a fine place for a preference and the wrong place for a thousand channels: Live TV is content, and it belongs where the content is. Home grows a rail of channel logos above the catalogue sections. A rail rather than a link, because a channel is picked in a second and watched immediately — tapping a logo plays it, and the header still opens the full page for folders and search. Logo-forward and square, since a channel is recognised by its mark long before its name is read. It loads itself and renders nothing until it has channels, so a backend with no line-up — or no network — leaves Home exactly as it was rather than showing an empty shelf. Live TV also remains available as a bottom-bar tab; it has been in the registry all along, just not on by default. --- .../presentation/widgets/home_content.dart | 7 + .../widgets/home_live_tv_section.dart | 257 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 lib/features/home/presentation/widgets/home_live_tv_section.dart diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 58c01df5..3a31b2ad 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -21,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'; @@ -236,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, 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..21dfe3dd --- /dev/null +++ b/lib/features/home/presentation/widgets/home_live_tv_section.dart @@ -0,0 +1,257 @@ +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/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) { + 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: [ + // The whole strip is the tap target, matching every other rail. + 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, + decoration: const 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, + ), + ), + ], + ), + ); + } +} From 9e5363c7c0aaff559f35abb67fcbf3b06dc0e93f Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 13:04:30 +0500 Subject: [PATCH 5/7] Drop a const the accent colour cannot live inside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppColors.primary is a getter — the accent is a user setting — so it cannot appear in a constant expression, which is exactly what app_colors.dart says at the top. The LIVE dot's decoration claimed const and failed to compile. --- .../home/presentation/widgets/home_live_tv_section.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/features/home/presentation/widgets/home_live_tv_section.dart b/lib/features/home/presentation/widgets/home_live_tv_section.dart index 21dfe3dd..646b303b 100644 --- a/lib/features/home/presentation/widgets/home_live_tv_section.dart +++ b/lib/features/home/presentation/widgets/home_live_tv_section.dart @@ -235,7 +235,9 @@ class _LiveBadge extends StatelessWidget { Container( width: 5, height: 5, - decoration: const BoxDecoration( + // 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, ), From 9c68c721621b663628281ec9697a23592a413c1e Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 14:23:54 +0500 Subject: [PATCH 6/7] feat(sync): enhance history synchronization and prevent data leaks between accounts --- lib/features/home/presentation/widgets/home_live_tv_section.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/home/presentation/widgets/home_live_tv_section.dart b/lib/features/home/presentation/widgets/home_live_tv_section.dart index 646b303b..0bce992b 100644 --- a/lib/features/home/presentation/widgets/home_live_tv_section.dart +++ b/lib/features/home/presentation/widgets/home_live_tv_section.dart @@ -80,7 +80,6 @@ class _LiveTvSectionState extends State<LiveTvSection> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // The whole strip is the tap target, matching every other rail. InkWell( onTap: () => context.push('/live-tv'), child: Padding( From 806de75018df8884282cc21ebb1daf07542de50c Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 16:33:41 +0500 Subject: [PATCH 7/7] Rebuild Live TV around what is actually on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen was a search box, a folder grid and a paged wall of logos. It told you a thousand channels exist and nothing about any of them, and the guide the backend had started returning was not read at all. Channels now carry their listing. A card shows what is on now, how far through it is, and what follows; a long press opens the channel with its schedule for the day. About 38% of the line-up has no guide, so every one of those paths is written to look deliberate without one rather than to show a gap where a title should be. Around that: categories and countries are one index rather than two competing navigations, pinned and recent channels lead, and the states that were bare — first load, loading more, no results, offline — are all drawn. Country names are in English, like the channel names beside them; a strip reading "O'zbekiston" over a grid of "Pluto TV Comedy" was two languages doing one job. The clock is the part worth being careful about. Times arrive as UTC and are converted on the way in, so an hour rendered is the viewer's own; progress is clamped at both ends so a stale payload reads as finished rather than as 140%; and a bar is only drawn for a slot between five minutes and six hours, because a feed padding a thin guide with a nine-hour block would otherwise show a bar that has not moved since breakfast. --- assets/translations/en.json | 21 +- assets/translations/ru.json | 21 +- assets/translations/uz.json | 21 +- .../widgets/home_live_tv_section.dart | 17 + .../live_tv/data/live_tv_service.dart | 388 +++- .../presentation/pages/live_tv_page.dart | 1661 +++++++++++------ .../presentation/widgets/channel_sheet.dart | 490 +++++ 7 files changed, 1957 insertions(+), 662 deletions(-) create mode 100644 lib/features/live_tv/presentation/widgets/channel_sheet.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 6db9aee8..59f199e4 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1207,18 +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", - "countries": "COUNTRIES", - "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 c5cdbc92..45f87f98 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1207,18 +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": "ПАПКИ", - "countries": "СТРАНЫ", - "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 850210ef..bb790208 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1207,18 +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", - "countries": "DAVLATLAR", - "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/features/home/presentation/widgets/home_live_tv_section.dart b/lib/features/home/presentation/widgets/home_live_tv_section.dart index 0bce992b..ab806a67 100644 --- a/lib/features/home/presentation/widgets/home_live_tv_section.dart +++ b/lib/features/home/presentation/widgets/home_live_tv_section.dart @@ -1,9 +1,12 @@ +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'; @@ -56,6 +59,20 @@ class _LiveTvSectionState extends State<LiveTvSection> { } 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( diff --git a/lib/features/live_tv/data/live_tv_service.dart b/lib/features/live_tv/data/live_tv_service.dart index bf5edac5..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 { @@ -13,6 +180,8 @@ class LiveChannel { this.language = '', this.category = '', this.headers = const {}, + this.now, + this.next, }); final String id; @@ -34,16 +203,55 @@ class LiveChannel { /// 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, - headers: headers, - ); + /// 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(); @@ -61,8 +269,8 @@ 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 => { @@ -71,18 +279,15 @@ class LiveChannel { }, _ => 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 { @@ -113,8 +318,6 @@ class LiveIndex { final List<LiveFolder> folders; final List<LiveCountry> countries; - - static const empty = LiveIndex(folders: [], countries: []); } /// One page of channels. @@ -131,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. @@ -222,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 f0da821c..2254313f 100644 --- a/lib/features/live_tv/presentation/pages/live_tv_page.dart +++ b/lib/features/live_tv/presentation/pages/live_tv_page.dart @@ -8,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. /// @@ -41,6 +60,10 @@ class _LiveTvPageState extends State<LiveTvPage> { 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 = ''; @@ -50,14 +73,31 @@ class _LiveTvPageState extends State<LiveTvPage> { 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(); @@ -65,57 +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 && _country.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()); + } + + /// 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; - /// What the crumb says you are inside of. - String get _openName => _folder.isNotEmpty ? _folder : _countryName(_country); + bool get _searching => _query.trim().isNotEmpty; - Future<void> _loadFolders() async { - setState(() { - _loading = true; - _error = null; - }); + 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 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 = index.folders; - _countries = index.countries; - _total = index.folders.fold(0, (n, f) => n + f.count); - _loading = false; + _folders = folders; + _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; } }); @@ -127,54 +190,57 @@ class _LiveTvPageState extends State<LiveTvPage> { 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; _country = ''; - _channels = const []; + _resetPaging(); }); if (_scroll.hasClients) _scroll.jumpTo(0); _loadPage(); @@ -184,27 +250,74 @@ class _LiveTvPageState extends State<LiveTvPage> { setState(() { _country = code; _folder = ''; - _channels = const []; + _resetPaging(); }); if (_scroll.hasClients) _scroll.jumpTo(0); _loadPage(); } - void _closeFolder() { + void _closeScope() { + _debounce?.cancel(); + _search.clear(); + _seq++; // drop anything already in flight for the scope being left setState(() { _folder = ''; _country = ''; - _channels = const []; _query = ''; + _resetPaging(); }); - _search.clear(); 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; @@ -215,39 +328,57 @@ class _LiveTvPageState extends State<LiveTvPage> { } // 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. + /// A country code as something readable, in English. /// - /// Only the ones this line-up actually carries are named; anything else keeps - /// its code, which is still better than an empty chip. Home comes first - /// wherever these are sorted, because it is what most people came for. + /// 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': 'O\'zbekiston', - 'RU': 'Rossiya', - 'TR': 'Turkiya', - 'KZ': 'Qozog\'iston', - 'KG': 'Qirg\'iziston', - 'TJ': 'Tojikiston', - 'TM': 'Turkmaniston', - 'AZ': 'Ozarbayjon', - 'US': 'AQSH', - 'UK': 'Buyuk Britaniya', - 'GB': 'Buyuk Britaniya', - 'CA': 'Kanada', - 'DE': 'Germaniya', - 'FR': 'Fransiya', + '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': 'BAA', - 'CN': 'Xitoy', - 'JP': 'Yaponiya', - 'KR': 'Koreya', - 'SG': 'Singapur', - 'IL': 'Isroil', - 'AT': 'Avstriya', - 'SK': 'Slovakiya', + '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) => @@ -255,8 +386,7 @@ class _LiveTvPageState extends State<LiveTvPage> { /// 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 — - /// every platform this runs on renders these. + /// 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 ''; @@ -313,6 +443,7 @@ class _LiveTvPageState extends State<LiveTvPage> { _favourites.add(channel.id); _cards[channel.id] = _cardOf(channel); } + _rebuildPins(); }); final hive = getIt<HiveService>(); hive.setLiveTvFavourites(_favourites.toList()); @@ -328,6 +459,7 @@ class _LiveTvPageState extends State<LiveTvPage> { _recent = [channel.id, ..._recent.where((e) => e != channel.id)] .take(12) .toList(); + _rebuildPins(); }); context.push( '/player', @@ -348,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( @@ -358,210 +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: _atTopLevel - ? 'live_tv.search_hint'.tr() - : 'live_tv.search_in'.tr(namedArgs: {'folder': _openName}), - 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 || _country.isNotEmpty) - SliverToBoxAdapter( - child: _FolderCrumb( - folder: _openName, - 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), - if (_countries.isNotEmpty) ...[ - _Header(label: 'live_tv.countries'.tr()), - _CountryRow( - countries: _countries, - nameOf: _countryName, - flagOf: _flagOf, - onOpen: _openCountry, - ), - ], - ] 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) { + // 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) { - return Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 6), - child: Row( + 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, + ), ), ), ], @@ -570,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: 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, ), ), ], @@ -795,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. @@ -861,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, ), @@ -876,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 @@ -892,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) - 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, + ), + ), + ], ), ), ), - ), - ], + ], + ), ), ), ), @@ -997,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, @@ -1018,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, ), @@ -1039,86 +1589,3 @@ class _Empty extends StatelessWidget { ); } } - -/// The countries the line-up covers, as a strip you scroll sideways. -/// -/// A row rather than a grid, and below the folders: it is a shortcut for -/// somebody who already knows what they are after, not the main navigation. -/// Ordered by how much each country actually contributes — the line-up leads -/// with channels people recognise, and pinning a home country in front of that -/// puts a provincial station where a famous one should be. -class _CountryRow extends StatelessWidget { - const _CountryRow({ - required this.countries, - required this.nameOf, - required this.flagOf, - required this.onOpen, - }); - - final List<LiveCountry> countries; - final String Function(String) nameOf; - final String Function(String) flagOf; - final ValueChanged<String> onOpen; - - @override - Widget build(BuildContext context) { - final ordered = [...countries]..sort((a, b) => b.count.compareTo(a.count)); - - return SliverToBoxAdapter( - child: SizedBox( - height: 40, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 14), - itemCount: ordered.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, i) { - final country = ordered[i]; - final flag = flagOf(country.code); - return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => onOpen(country.code), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 13), - alignment: Alignment.center, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (flag.isNotEmpty) ...[ - Text(flag, style: const TextStyle(fontSize: 15)), - const SizedBox(width: 7), - ], - Text( - nameOf(country.code), - style: const TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(width: 6), - Text( - '${country.count}', - style: const TextStyle( - fontSize: 10.5, - fontWeight: FontWeight.w700, - color: AppColors.textHint, - ), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ); - } -} 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')}';