From e78c80eab1a6896ffb2a6d108dfa530ef49fd747 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:40:07 +0000 Subject: [PATCH 1/2] Add Jetpack Compose audit report (68/100) --- COMPOSE-AUDIT-REPORT.md | 176 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 COMPOSE-AUDIT-REPORT.md diff --git a/COMPOSE-AUDIT-REPORT.md b/COMPOSE-AUDIT-REPORT.md new file mode 100644 index 000000000..dd5e84b59 --- /dev/null +++ b/COMPOSE-AUDIT-REPORT.md @@ -0,0 +1,176 @@ +# Jetpack Compose Audit Report + +Target: oxyroid/M3UAndroid (repository root) +Date: 2026-06-11 +Scope: `app/smartphone`, `app/tv`, `app/extension`, `business/*`, `core/foundation` (Compose surfaces) +Excluded from scoring: `testing/*`, `baselineprofile/*` (cited as positive evidence only), `docs/`, `.claude/`, `lint/`, `data/` (no Compose UI) +Confidence: Medium +Overall Score: 68/100 + +## Scorecard + +| Category | Score | Weight | Status | Notes | +|----------|-------|--------|--------|-------| +| Performance | 7/10 | 35% | solid | Strong stability hygiene; a few unkeyed lazy lists and in-composition allocations. Capped at 7 (no compiler reports). | +| State management | 7/10 | 25% | solid | Exemplary lifecycle-aware collection and `stateIn`; `MutableState` leaks into ViewModels and the business layer. | +| Side effects | 7/10 | 20% | solid | Disciplined effect usage overall; `LaunchedEffect(Unit)` captures changeable state in the player screen. | +| Composable API quality | 6/10 | 20% | needs work | Good modifier/i18n conventions; zero `@Preview`, hardcoded colors, giant composables, awkward parameter ordering. | + +`overall = (7×0.35 + 7×0.25 + 7×0.20 + 6×0.20) × 10 = 68` + +## Critical Findings + +1. **API quality: zero `@Preview` coverage across 124 Compose UI files** + - Why it matters: reusable components (`core/foundation`, `app/smartphone/ui/material/components`) cannot be verified in isolation; hidden ambient dependencies (e.g. `LocalHelper`, which `error()`s without a provider) go undetected until full-app runs. + - Evidence: `grep -rn '@Preview' app business core` → 0 matches; e.g. `app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/TextFields.kt`, `core/foundation/src/main/java/com/m3u/core/foundation/components/CircularProgressIndicator.kt` + - Fix direction: add `@Preview` configurations to shared components first (material/components, core/foundation), then to screen-level pieces. + - References: + +2. **Performance: lazy lists without stable keys; `contentType` never used** + - Why it matters: item moves/removals lose state and force full item recomposition; selection tracking in track/device sheets is identity-fragile. `contentType` appears 0 times in the repo. + - Evidence: `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/DlnaDevicesBottomSheet.kt:118` (`items(devices)`), `.../components/FormatsBottomSheet.kt:101` (`items(currentTracks)`), `.../components/PlayerPanel.kt:401` (`items(value.episodes)`) + - Fix direction: add `key = { ... }` from stable IDs (device UDN, track group+index, episode id) and `contentType` on heterogeneous lists. + - References: + +3. **State management: Compose `MutableState` used as ViewModel/business-layer state** + - Why it matters: couples ViewModels and the `business/setting` module to the Compose runtime, hurts testability, and contradicts the repo's own KMP direction (AGENTS.md: business logic should avoid Android/Compose framework APIs). The repo otherwise uses `StateFlow` + `stateIn` consistently, so this is an inconsistency, not the norm. + - Evidence: `app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt:69` (`var searchQuery = mutableStateOf("")`), `:83-86` (`code`, `isConnectSheetVisible`, `connectedTv`, `connectionToTvValue`); `business/setting/src/main/java/com/m3u/business/setting/SettingProperties.kt:8-19` (10 `MutableState` fields exposed as a bundle) + - Fix direction: replace with `MutableStateFlow` exposed as `StateFlow`; turn `SettingProperties` into an immutable UI-state data class with update events. + - References: + +4. **Side effects: `LaunchedEffect(Unit)` gating long-lived collectors on changeable state** + - Why it matters: `isSupportBrightnessGesture` is a `derivedStateOf` (`brightness != -1f`) evaluated once at effect launch; if support status changes, the brightness collector never starts (or keeps running). The neighbouring `DisposableEffect(Unit)` inside `if (brightnessGestureEnabled)` captures `prev` only on first entry. + - Evidence: `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt:182-205`, `:207-213` + - Fix direction: key the effect on `isSupportBrightnessGesture` (or move the condition inside the snapshotFlow), and key the `DisposableEffect` on `brightnessGestureEnabled`. + - References: + +5. **API quality: hardcoded colors in shared components break theming/dark mode** + - Why it matters: the favourite-star gold is duplicated in two files, and a `core/foundation` defaults object hardcodes `Color.Gray` — every consumer inherits a color that ignores `MaterialTheme.colorScheme` and dark mode. + - Evidence: `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt:306` (`Color(0xffffcd3c)`), `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt:96` (same literal), `core/foundation/src/main/java/com/m3u/core/foundation/components/CircularProgressIndicator.kt:60` (`val color = Color.Gray`), `app/smartphone/src/main/java/com/m3u/smartphone/ui/material/brush/Scrim.kt:10-11` + - Fix direction: route through `MaterialTheme.colorScheme` (or one named theme token for the star color). + - References: , + +## Category Details + +### Performance — 7/10 + +**Performance ceiling check** + +``` +Compiler reports: NOT generated (build fails: git submodules `native-load-gradle-plugin` +and `parser` cannot be cloned in this sandbox, so the `dev.oxyroid.native-load` +included-build plugin is unresolvable). +Rule applied: without compiler diagnostics, cap Performance at 7. +qualitative score: 7 +applied score: 7 (at cap) +``` + +**What is working** + +- `compose_compiler_config.conf` marks third-party types (`Uri`, Media3 `Format`/`Download`, `kotlinx.datetime.LocalDateTime`, …) stable — rare, deliberate stability hygiene. · References: +- ~50 `@Immutable`/`@Stable` annotations across `core` and UI models (`MaskState` is `@Stable`). · References: +- Typed state factories used (`mutableFloatStateOf`, `mutableIntStateOf` — `ChannelScreen.kt:122-124`, `CanvasBottomSheet.kt:61`). · References: +- High-traffic lists are keyed: `ChannelGallery.kt:385` and `PlayerPanel.kt:385` use `channels.itemKey { it.id }`; `PlaylistGallery.kt:96` keys on `playlist.url`. · References: +- Release hygiene: `app/smartphone/build.gradle.kts:29` `isMinifyEnabled = true`; dedicated `baselineprofile/smartphone` and `baselineprofile/tv` modules. · References: + +**What is hurting the score** + +- Three unkeyed `items(...)` calls and zero `contentType` usage repo-wide (Critical Finding 2). +- `Map.entries.toList()` allocated directly in composition/lazy builders. +- A four-deep `derivedStateOf` chain whose inputs change exactly as often as outputs. + +**Evidence** + +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/DlnaDevicesBottomSheet.kt:118` — `items(devices)` without `key` · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatsBottomSheet.kt:101` — `items(currentTracks)` without `key`; selection matched by field comparison instead · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistGallery.kt:95` — `playlists.entries.toList()` re-allocated every recomposition (same pattern in `PlaylistScreen.kt:414`, `EpgManifestGallery.kt:52`) · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/CanvasBottomSheet.kt:83-90` — chained `derivedStateOf` for `red`/`green`/`blue` derived from a value that changes at the same rate; pure overhead · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/ProgrammeGuide.kt:495-505` — 1-second ticking `produceState` recomposes the guide continuously while expanded; consider deferring the read · References: +- Stability of shared model classes (e.g. `Channel`, `Playlist` Room entities passed into composables) is **inferred from source, not verified against compiler reports** — see Notes And Limits. + +### State Management — 7/10 + +**What is working** + +- 63 `collectAsStateWithLifecycle()` call sites vs. a single bare `collectAsState(` — effectively 100% lifecycle-aware collection. · References: +- ViewModels consistently expose `StateFlow` via `stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), …)` (e.g. `business/setting/src/main/java/com/m3u/business/setting/SettingViewModel.kt:71-85`). · References: +- `rememberSaveable` used for recreate-surviving UI state (sort sheet, focus category in `PlaylistScreen.kt`, `FavouriteScreen.kt`). · References: +- `remember` correctly keyed where inputs change: `ForyouScreen.kt:186` keys on `configuration.orientation`. · References: + +**What is hurting the score** + +- `mutableStateOf` as ViewModel state and `MutableState`-bundle in the business layer (Critical Finding 3). The official guidance treats ViewModel `mutableStateOf` as an acceptable team tradeoff, but here it contradicts the repo's own architecture rules and its otherwise-consistent `StateFlow` pattern. +- String-based navigation routes (`Destination.Foryou.name` as `startDestination`, `business/playlist/PlaylistNavigation.kt` route constants) on a Navigation Compose 2.8+ project where type-safe `@Serializable` routes are available. + +**Evidence** + +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt:69,83-86` — five `mutableStateOf` properties as ViewModel state; `:44` builds paging off `snapshotFlow { searchQuery.value }` instead of a `MutableStateFlow` · References: +- `business/setting/src/main/java/com/m3u/business/setting/SettingProperties.kt:8-19` — business module exposes ten raw `MutableState` fields; split ownership between ViewModel and UI · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/common/AppNavHost.kt:35` — string route (`Destination.Foryou.name`) instead of type-safe routes · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt:122` — `remember { mutableFloatStateOf(helper.brightness) }` captures a stale initial value if `helper.brightness` changes externally; tolerable for transient gesture state but fragile · References: + +### Side Effects — 7/10 + +**What is working** + +- Navigation, snackbar, and repository calls live in event handlers/callbacks — no `navigate(...)` in composition bodies was found. · References: +- `snapshotFlow { … }` is collected from inside `LaunchedEffect` (the canonical pattern), e.g. `ChannelScreen.kt:184-204`. · References: +- 26 `rememberCoroutineScope()` sites are all event-driven work; no lifecycle-reinvention found. · References: +- `rememberUpdatedState` used to avoid stale captures in long-lived gesture effects (`ChannelScreen.kt:402-404`). · References: +- `DisposableEffect` cleanup restores prior system brightness (`ChannelScreen.kt:207-213`). · References: + +**What is hurting the score** + +- `LaunchedEffect(Unit)` bodies that branch on changeable state without keys or `rememberUpdatedState` (Critical Finding 4). This is the dominant effect smell, concentrated in the player screen (8 of ~14 repo-wide `LaunchedEffect(Unit)` sites are in `ChannelScreen.kt`). +- `DisposableEffect(Unit)` whose captured `prev` value belongs to a condition (`brightnessGestureEnabled`) that is not a key. + +**Evidence** + +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt:182-187` — `if (isSupportBrightnessGesture)` evaluated once inside `LaunchedEffect(Unit)`; collector never starts/stops when support changes · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt:207-213` — `DisposableEffect(Unit)` should be keyed on `brightnessGestureEnabled` so `prev` re-captures on re-entry · References: + +### Composable API Quality — 6/10 + +**What is working** + +- Shared components consistently expose `modifier: Modifier = Modifier` in the conventional position. · References: +- User-facing strings route through the dedicated `i18n` module via `stringResource`; no hardcoded UI text found. · References: +- `Modifier.composed { }` used only where CompositionLocal access genuinely requires it (4 sites, e.g. `ui/material/ktx/Modifier.kt:16`). · References: +- `ComponentDefaults`-style objects exist (`TextFieldDefaults`, `CircularProgressIndicatorDefaults`). · References: +- Theme tokens (`LocalSpacing`, `LocalDuration`, `LocalGradientColors`) are legitimate tree-scoped data with defaults. + +**What is hurting the score** + +- Zero `@Preview` annotations in 124 Compose UI files (Critical Finding 1). +- Hardcoded colors in shared components, including a `core/foundation` defaults object (Critical Finding 5). +- Parameter ordering violations in widely used internal APIs. +- Monolithic composables: `ChannelMask.kt` (755 lines), `PlayerPanel.kt` (539), `ChannelScreen.kt` (523), `ProgrammeGuide.kt` (505). +- `LocalHelper` (`staticCompositionLocalOf { error(...) }`) carries an app-scoped service object through the tree with no sensible default — component-specific configuration via a Local. + +**Evidence** + +- `core/foundation/src/main/java/com/m3u/core/foundation/components/CircularProgressIndicator.kt:60` — foundation default `Color.Gray`, ignores theme/dark mode · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt:90-105` — `modifier` buried after 10 required params, with more required callbacks after it · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt:116-755` — single composable owns controls, gestures, animation, and state · References: +- `app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt:168` — `LocalHelper` with `error()` default; service-object via CompositionLocal · References: + +## Prioritized Fixes + +1. **Add stable `key =` (and `contentType`) to the three unkeyed lazy lists** — `DlnaDevicesBottomSheet.kt:118`, `FormatsBottomSheet.kt:101`, `PlayerPanel.kt:401`. Cheap, mechanical, removes the only crash/state-loss-class list issue. () +2. **Migrate `AppViewModel` `mutableStateOf` fields and `SettingProperties` to `StateFlow`/immutable UI state** — `AppViewModel.kt:69,83-86`, `SettingProperties.kt:8-19`. Aligns with the repo's KMP direction and its own `stateIn(WhileSubscribed(5_000))` convention. () +3. **Key the player gesture effects on their gating state** — `ChannelScreen.kt:182-205` (`LaunchedEffect`) and `:207-213` (`DisposableEffect`) keyed on `isSupportBrightnessGesture` / `brightnessGestureEnabled`. Fixes a real behavioral bug class (brightness collector frozen at first composition). () +4. Replace hardcoded colors with theme tokens (`ChannelMask.kt:306`, `ChannelItem.kt:96`, `CircularProgressIndicator.kt:60`, `Scrim.kt:10-11`) and start adding `@Preview` to `core/foundation` and `ui/material/components`. + +## Notes And Limits + +- **Compiler diagnostics used: no.** The Gradle build with the audit init script fails before configuration: the repo depends on two git submodules (`native-load-gradle-plugin` included build providing the `dev.oxyroid.native-load` plugin, and `parser`) that could not be cloned in this sandbox (network-restricted). All stability/skippability claims are **inferred from source, not measured**; Performance is capped at 7 accordingly and overall confidence reduced from High to Medium. +- `data/` module and TV module (`app/tv`) were sampled more lightly than `app/smartphone`; TV-specific focus behavior is out of scope for this rubric (a separate `android-tv-focus-audit` exists in this repo). +- Accompanist **permissions** usage (4 files) was *not* deducted: unlike pager/swiperefresh/flowlayout/systemuicontroller, there is no first-party Compose replacement for it. +- Accessibility, test coverage, and Material 3 token compliance were not scored (v1 scope). +- Weight choice: default 35/25/20/20. +- Renormalization: none — no N/A categories. + +## Suggested Follow-Up + +- Run a `material-3` audit: the app is fully Material 3 but the hardcoded-color findings and 6 CompositionLocal theme channels suggest token-compliance gaps worth a dedicated pass. +- Re-run this audit with submodules initialized (CI or local) so compiler reports can verify skippability of `Channel`/`Playlist` entity params passed into composables. From f5a43e645b94043a83b394a50add3365739fd779 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:44:43 +0000 Subject: [PATCH 2/2] Fix UI design issues: theme tokens, lazy keys, effect keying, adaptive grids --- .../ui/business/channel/ChannelMask.kt | 3 ++- .../ui/business/channel/ChannelScreen.kt | 6 +++-- .../components/DlnaDevicesBottomSheet.kt | 8 +++++-- .../channel/components/FormatsBottomSheet.kt | 6 ++++- .../channel/components/PlayerPanel.kt | 2 +- .../components/SyncProgrammesButton.kt | 2 +- .../ui/business/favourite/FavouriteScreen.kt | 10 ++------- .../favourite/components/FavoriteGallery.kt | 2 +- .../ui/business/foryou/ForyouScreen.kt | 11 ++-------- .../foryou/components/PlaylistItem.kt | 2 +- .../ui/business/playlist/PlaylistScreen.kt | 14 ++---------- .../playlist/components/ChannelGallery.kt | 2 +- .../playlist/components/ChannelItem.kt | 7 +++--- .../m3u/smartphone/ui/common/helper/Helper.kt | 15 +++++++++++++ .../components/EpisodesBottomSheet.kt | 1 - .../material/components/ProgressIndicators.kt | 22 +++++++++++++++++++ .../smartphone/ui/material/model/Colors.kt | 6 +++++ 17 files changed, 75 insertions(+), 44 deletions(-) create mode 100644 app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ProgressIndicators.kt create mode 100644 app/smartphone/src/main/java/com/m3u/smartphone/ui/material/model/Colors.kt diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt index c4536144f..27cd44853 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt @@ -102,6 +102,7 @@ import com.m3u.smartphone.ui.material.components.mask.MaskCircleButton import com.m3u.smartphone.ui.material.components.mask.MaskPanel import com.m3u.smartphone.ui.material.components.mask.MaskState import com.m3u.smartphone.ui.material.effects.currentBackStackEntry +import com.m3u.smartphone.ui.material.model.FavoriteColor import com.m3u.smartphone.ui.material.model.LocalSpacing import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -303,7 +304,7 @@ fun ChannelMask( MaskButton( state = maskState, icon = Icons.Rounded.Star, - tint = if (favourite) Color(0xffffcd3c) else Color.Unspecified, + tint = if (favourite) FavoriteColor else Color.Unspecified, onClick = onFavorite, contentDescription = if (favourite) stringResource(string.feat_channel_tooltip_unfavourite) else stringResource(string.feat_channel_tooltip_favourite) diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt index d2b711a18..567d5d134 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt @@ -179,14 +179,16 @@ fun ChannelRoute( } } - LaunchedEffect(Unit) { + LaunchedEffect(isSupportBrightnessGesture) { if (isSupportBrightnessGesture) { snapshotFlow { brightness } .drop(1) .onEach { helper.brightness = it } .launchIn(this) } + } + LaunchedEffect(Unit) { snapshotFlow { isBarVisible } .onEach { isBarVisible -> helper.statusBarVisibility = isBarVisible @@ -205,7 +207,7 @@ fun ChannelRoute( } if (brightnessGestureEnabled) { - DisposableEffect(Unit) { + DisposableEffect(brightnessGestureEnabled) { val prev = helper.brightness onDispose { helper.brightness = prev diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/DlnaDevicesBottomSheet.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/DlnaDevicesBottomSheet.kt index 8ac323b4f..39888c254 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/DlnaDevicesBottomSheet.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/DlnaDevicesBottomSheet.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.m3u.core.foundation.util.basic.title import com.m3u.i18n.R.string -import com.m3u.core.foundation.components.CircularProgressIndicator +import com.m3u.smartphone.ui.material.components.CircularProgressIndicator import androidx.compose.material3.Icon import com.m3u.smartphone.ui.material.components.mask.MaskState import com.m3u.smartphone.ui.material.model.LocalSpacing @@ -115,7 +115,11 @@ internal fun DlnaDevicesBottomSheet( } ) } - items(devices) { device -> + items( + items = devices, + key = { it.udn }, + contentType = { "device" } + ) { device -> DlnaDeviceItem( device = device, onClick = { connectDlnaDevice(device) }, diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatsBottomSheet.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatsBottomSheet.kt index 0660a1792..b2df9ff01 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatsBottomSheet.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatsBottomSheet.kt @@ -98,7 +98,11 @@ internal fun FormatsBottomSheet( LazyColumn( modifier = Modifier.fillMaxWidth() ) { - items(currentTracks) { track -> + items( + items = currentTracks, + key = { "${it.group.hashCode()}-${it.index}" }, + contentType = { "track" } + ) { track -> val selected = selectedTrack != null && track.group == selectedTrack.group && track.index == selectedTrack.index diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt index 0a449842d..68de80816 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt @@ -62,7 +62,7 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import coil.compose.SubcomposeAsyncImage import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape -import com.m3u.core.foundation.components.CircularProgressIndicator +import com.m3u.smartphone.ui.material.components.CircularProgressIndicator import com.m3u.core.foundation.ui.composableOf import com.m3u.core.foundation.ui.thenIf import com.m3u.core.foundation.util.collections.indexOf diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/SyncProgrammesButton.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/SyncProgrammesButton.kt index e7c9df021..cf6720f65 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/SyncProgrammesButton.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/SyncProgrammesButton.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.m3u.core.foundation.util.basic.title import com.m3u.i18n.R.string -import com.m3u.core.foundation.components.CircularProgressIndicator +import com.m3u.smartphone.ui.material.components.CircularProgressIndicator import com.m3u.smartphone.ui.material.components.SelectionsDefaults import com.m3u.smartphone.ui.material.model.LocalSpacing import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt index 32b88a065..82eb5fa53 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt @@ -1,6 +1,5 @@ package com.m3u.smartphone.ui.business.favourite -import android.content.res.Configuration import android.view.KeyEvent import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -16,7 +15,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -39,6 +37,7 @@ import com.m3u.i18n.R import com.m3u.smartphone.ui.business.favourite.components.FavoriteGallery import com.m3u.smartphone.ui.common.helper.Action import com.m3u.smartphone.ui.common.helper.LocalHelper +import com.m3u.smartphone.ui.common.helper.adaptiveRowCount import com.m3u.smartphone.ui.common.helper.Metadata import com.m3u.smartphone.ui.material.components.EpisodesBottomSheet import com.m3u.smartphone.ui.material.components.MediaSheet @@ -191,12 +190,7 @@ private fun FavoriteScreen( onLongClickChannel: (Channel) -> Unit, modifier: Modifier = Modifier ) { - val configuration = LocalConfiguration.current - val actualRowCount = when (configuration.orientation) { - Configuration.ORIENTATION_PORTRAIT -> rowCount - Configuration.ORIENTATION_LANDSCAPE -> rowCount + 2 - else -> rowCount + 2 - } + val actualRowCount = LocalHelper.current.adaptiveRowCount(rowCount) FavoriteGallery( contentPadding = contentPadding, channels = channels, diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteGallery.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteGallery.kt index de941137f..bc799e5f3 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteGallery.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteGallery.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey -import com.m3u.core.foundation.components.CircularProgressIndicator +import com.m3u.smartphone.ui.material.components.CircularProgressIndicator import com.m3u.data.database.model.Channel import com.m3u.smartphone.ui.material.ktx.plus import com.m3u.smartphone.ui.material.model.LocalSpacing diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt index 0324335cf..64c9f0212 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt @@ -1,6 +1,5 @@ package com.m3u.smartphone.ui.business.foryou -import android.content.res.Configuration.ORIENTATION_PORTRAIT import android.view.KeyEvent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -22,7 +21,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -51,6 +49,7 @@ import com.m3u.smartphone.ui.business.foryou.components.PlaylistGallery import com.m3u.smartphone.ui.business.foryou.components.recommend.RecommendGallery import com.m3u.smartphone.ui.common.helper.Action import com.m3u.smartphone.ui.common.helper.LocalHelper +import com.m3u.smartphone.ui.common.helper.adaptiveRowCount import com.m3u.smartphone.ui.common.helper.Metadata import com.m3u.smartphone.ui.material.components.EpisodesBottomSheet import com.m3u.smartphone.ui.material.components.MediaSheet @@ -178,17 +177,11 @@ private fun ForyouScreen( onUnsubscribePlaylist: (playlistUrl: String) -> Unit, modifier: Modifier = Modifier ) { - val configuration = LocalConfiguration.current val lifecycleOwner = LocalLifecycleOwner.current var headlineSpec: Recommend.Spec? by remember { mutableStateOf(null) } - val actualRowCount = remember(rowCount, configuration.orientation) { - when (configuration.orientation) { - ORIENTATION_PORTRAIT -> rowCount - else -> rowCount + 2 - } - } + val actualRowCount = LocalHelper.current.adaptiveRowCount(rowCount) var mediaSheetValue: MediaSheetValue.ForyouScreen by remember { mutableStateOf(MediaSheetValue.ForyouScreen()) } diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistItem.kt index 36fe35dc9..37aa43321 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistItem.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistItem.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.m3u.core.foundation.components.CircularProgressIndicator +import com.m3u.smartphone.ui.material.components.CircularProgressIndicator import androidx.compose.material3.Icon import com.m3u.smartphone.ui.material.model.LocalSpacing import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt index d54f386fe..405cf3da7 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt @@ -2,8 +2,6 @@ package com.m3u.smartphone.ui.business.playlist import android.Manifest import android.content.Intent -import android.content.res.Configuration.ORIENTATION_LANDSCAPE -import android.content.res.Configuration.ORIENTATION_PORTRAIT import android.content.res.Configuration.UI_MODE_TYPE_APPLIANCE import android.content.res.Configuration.UI_MODE_TYPE_CAR import android.content.res.Configuration.UI_MODE_TYPE_DESK @@ -51,7 +49,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -80,6 +77,7 @@ import com.m3u.smartphone.ui.business.playlist.components.PlaylistTabRow import com.m3u.smartphone.ui.common.helper.Action import com.m3u.smartphone.ui.common.helper.Fob import com.m3u.smartphone.ui.common.helper.LocalHelper +import com.m3u.smartphone.ui.common.helper.adaptiveRowCount import com.m3u.smartphone.ui.common.helper.Metadata import com.m3u.smartphone.ui.material.components.Destination import com.m3u.smartphone.ui.material.components.EpisodesBottomSheet @@ -336,7 +334,6 @@ private fun PlaylistScreen( onDispose { Metadata.fob = null } } - val configuration = LocalConfiguration.current val sheetState = rememberModalBottomSheetState() @@ -374,14 +371,7 @@ private fun PlaylistScreen( EventHandler(state.scrollUp) { gridState.scrollToItem(0) } - val orientation = configuration.orientation - val actualRowCount = remember(orientation, state.rowCount) { - when (orientation) { - ORIENTATION_LANDSCAPE -> state.rowCount + 2 - ORIENTATION_PORTRAIT -> state.rowCount - else -> state.rowCount - } - } + val actualRowCount = LocalHelper.current.adaptiveRowCount(state.rowCount) var isExpanded by remember(state.sort == Sort.MIXED) { mutableStateOf(false) } diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt index d723bba6d..0a9a6aab9 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt @@ -28,7 +28,7 @@ import com.m3u.business.playlist.ChannelWithProgramme import com.m3u.data.database.model.Channel import com.m3u.core.foundation.architecture.preferences.PreferencesKeys import com.m3u.core.foundation.architecture.preferences.preferenceOf -import com.m3u.core.foundation.components.CircularProgressIndicator +import com.m3u.smartphone.ui.material.components.CircularProgressIndicator import com.m3u.smartphone.ui.material.ktx.plus import com.m3u.smartphone.ui.material.model.LocalSpacing import kotlinx.coroutines.delay diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt index be2629f11..81f63edaf 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt @@ -44,12 +44,13 @@ import coil.request.ImageRequest import coil.size.Size import com.m3u.core.foundation.architecture.preferences.PreferencesKeys import com.m3u.core.foundation.architecture.preferences.preferenceOf -import com.m3u.core.foundation.components.CircularProgressIndicator +import com.m3u.smartphone.ui.material.components.CircularProgressIndicator import com.m3u.data.database.model.Channel import com.m3u.data.database.model.Programme import com.m3u.i18n.R.string import com.m3u.smartphone.TimeUtils.formatEOrSh import com.m3u.core.foundation.ui.composableOf +import com.m3u.smartphone.ui.material.model.FavoriteColor import com.m3u.smartphone.ui.material.model.LocalSpacing import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape import kotlin.time.Clock @@ -92,8 +93,8 @@ internal fun ChannelItem( if (favourite) { Icon( imageVector = Icons.Rounded.Star, - contentDescription = null, - tint = Color(0xffffcd3c) + contentDescription = stringResource(string.feat_channel_tooltip_favourite), + tint = FavoriteColor ) } } diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt index a80d88992..0266fb150 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt @@ -2,6 +2,7 @@ package com.m3u.smartphone.ui.common.helper import android.app.PictureInPictureParams import android.content.Context +import android.content.res.Configuration import android.graphics.Color import android.graphics.Rect import android.provider.Settings @@ -15,6 +16,7 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalConfiguration import androidx.core.app.PictureInPictureModeChangedInfo import androidx.core.util.Consumer import androidx.core.view.WindowInsetsCompat @@ -165,4 +167,17 @@ class Helper(private val activity: ComponentActivity) { val Helper.useRailNav: Boolean @Composable get() = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact +@Composable +fun Helper.adaptiveRowCount(rowCount: Int): Int { + val landscapeExtra = + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) 2 + else 0 + val widthExtra = when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Expanded -> 2 + WindowWidthSizeClass.Medium -> 1 + else -> 0 + } + return rowCount + maxOf(landscapeExtra, widthExtra) +} + val LocalHelper = staticCompositionLocalOf { error("Please provide helper.") } diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/EpisodesBottomSheet.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/EpisodesBottomSheet.kt index 5034314b3..f7d7f0834 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/EpisodesBottomSheet.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/EpisodesBottomSheet.kt @@ -35,7 +35,6 @@ import com.m3u.core.foundation.wrapper.Resource import com.m3u.data.database.model.Channel import com.m3u.data.parser.xtream.XtreamEpisodeInfo import androidx.compose.material3.IconButton -import com.m3u.core.foundation.components.CircularProgressIndicator import com.m3u.smartphone.ui.material.model.LocalSpacing import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ProgressIndicators.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ProgressIndicators.kt new file mode 100644 index 000000000..5c1638ddd --- /dev/null +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ProgressIndicators.kt @@ -0,0 +1,22 @@ +package com.m3u.smartphone.ui.material.components + +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.m3u.core.foundation.components.CircularProgressIndicator as FoundationCircularProgressIndicator + +@Composable +fun CircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + size: Dp = 16.dp +) { + FoundationCircularProgressIndicator( + modifier = modifier, + color = color, + size = size + ) +} diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/model/Colors.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/model/Colors.kt new file mode 100644 index 000000000..7fbee25ba --- /dev/null +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/model/Colors.kt @@ -0,0 +1,6 @@ +package com.m3u.smartphone.ui.material.model + +import androidx.compose.ui.graphics.Color + +/** Shared semantic color for "favourite" star indicators. */ +val FavoriteColor = Color(0xFFFFCD3C)