Skip to content

Commit 95af8bb

Browse files
committed
feat: new UI.
1 parent 324ab2d commit 95af8bb

File tree

27 files changed

+362
-967
lines changed

27 files changed

+362
-967
lines changed

RULES.md

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22

33
1. Never use Java, use Kotlin always.
44
2. Do not import-on-demand (star-import).
5-
3. All composable functions without return types should be restartable and skippable.
6-
4. Do not use List/Map/Set and other unstable collections as parameters for composable functions.
7-
Instead, consider using stable wrapper
8-
or immutable data structures with immutable/stable elements.
5+
3. Most of composable functions without return types should be restartable and skippable.
6+
4. You can use List/Map/Set for composable functions because we add these types to compose_compiler_config.
97
5. If you want to change the visibility of the system bars, you can do so by calling
108
`Helper#statusBarsVisibility` or `Helper#navigationBarsVisibility`.
119
6. If you want to create a new string resource, you can do so by creating it in the i18n module and
@@ -16,8 +14,3 @@
1614
10. Never use Painter to inflate drawable resources, use `ImageVector.vectorResource` instead.
1715
11. If you wanna to add some libraries, please make sure they are located in MavenCentral, google or
1816
jitpack repository. And jar library is not allowed as well.
19-
12. Due to compatibility needs, for data table `playlists` and `streams`,
20-
please do not change the existing column names(referring to the real field names mapped to the
21-
database, that is, the name field defined in ColumnInfo),
22-
and remember the new fields must have default value (it needs to be defined in the
23-
defaultValue field in both the data class and ColumnInfo).

app/smartphone/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,9 @@ dependencies {
149149
implementation(libs.androidx.compose.material3)
150150
implementation(libs.androidx.compose.material3.window.size.clazz)
151151
implementation(libs.androidx.compose.material3.adaptive)
152-
implementation(libs.androidx.compose.material3.adaptive.navigation)
153152
implementation(libs.androidx.compose.material3.adaptive.layout)
153+
implementation(libs.androidx.compose.material3.adaptive.navigation)
154+
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
154155
// glance
155156
implementation(libs.androidx.glance.appwidget)
156157
implementation(libs.androidx.glance.material3)

app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt

Lines changed: 145 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,89 +2,102 @@ package com.m3u.smartphone.ui
22

33
import android.app.ActivityOptions
44
import android.content.Intent
5-
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
5+
import androidx.activity.compose.BackHandler
66
import androidx.compose.foundation.layout.Arrangement
7+
import androidx.compose.foundation.layout.Column
78
import androidx.compose.foundation.layout.Row
8-
import androidx.compose.foundation.layout.fillMaxSize
9+
import androidx.compose.foundation.layout.WindowInsets
10+
import androidx.compose.foundation.layout.asPaddingValues
911
import androidx.compose.foundation.layout.fillMaxWidth
12+
import androidx.compose.foundation.layout.ime
1013
import androidx.compose.foundation.layout.padding
14+
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
15+
import androidx.compose.foundation.text.input.rememberTextFieldState
16+
import androidx.compose.material.icons.Icons
17+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
18+
import androidx.compose.material.icons.filled.MoreVert
19+
import androidx.compose.material.icons.filled.Search
20+
import androidx.compose.material3.ExpandedFullScreenSearchBar
21+
import androidx.compose.material3.Icon
22+
import androidx.compose.material3.IconButton
23+
import androidx.compose.material3.SearchBarDefaults
24+
import androidx.compose.material3.SearchBarValue
25+
import androidx.compose.material3.Text
26+
import androidx.compose.material3.TopSearchBar
27+
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
28+
import androidx.compose.material3.rememberSearchBarState
1129
import androidx.compose.runtime.Composable
1230
import androidx.compose.runtime.derivedStateOf
1331
import androidx.compose.runtime.getValue
1432
import androidx.compose.runtime.remember
33+
import androidx.compose.runtime.rememberCoroutineScope
1534
import androidx.compose.ui.Alignment
1635
import androidx.compose.ui.Modifier
1736
import androidx.compose.ui.platform.LocalContext
37+
import androidx.compose.ui.res.stringResource
1838
import androidx.hilt.navigation.compose.hiltViewModel
1939
import androidx.navigation.NavHostController
2040
import androidx.navigation.compose.currentBackStackEntryAsState
2141
import androidx.navigation.compose.rememberNavController
2242
import androidx.navigation.navOptions
43+
import androidx.paging.PagingData
2344
import com.m3u.core.architecture.preferences.PreferencesKeys
2445
import com.m3u.core.architecture.preferences.preferenceOf
46+
import com.m3u.data.database.model.Channel
47+
import com.m3u.data.service.MediaCommand
2548
import com.m3u.smartphone.ui.business.channel.PlayerActivity
49+
import com.m3u.smartphone.ui.business.playlist.components.ChannelGallery
2650
import com.m3u.smartphone.ui.common.AppNavHost
27-
import com.m3u.smartphone.ui.common.Scaffold
51+
import com.m3u.smartphone.ui.common.helper.LocalHelper
2852
import com.m3u.smartphone.ui.material.components.Destination
2953
import com.m3u.smartphone.ui.material.components.SnackHost
3054
import com.m3u.smartphone.ui.material.model.LocalSpacing
55+
import kotlinx.coroutines.flow.Flow
56+
import kotlinx.coroutines.launch
3157

3258
@Composable
3359
fun App(
3460
modifier: Modifier = Modifier,
3561
viewModel: AppViewModel = hiltViewModel(),
3662
) {
37-
val onBackPressedDispatcher = checkNotNull(
38-
LocalOnBackPressedDispatcherOwner.current
39-
).onBackPressedDispatcher
40-
4163
val navController = rememberNavController()
42-
val entry by navController.currentBackStackEntryAsState()
43-
44-
val shouldDispatchBackStack by remember {
45-
derivedStateOf {
46-
with(entry) {
47-
this != null && destination.route in Destination.Root.entries.map { it.name }
48-
}
49-
}
50-
}
51-
52-
val onBackPressed: (() -> Unit) = {
53-
onBackPressedDispatcher.onBackPressed()
54-
}
5564

5665
AppImpl(
5766
navController = navController,
58-
onBackPressed = onBackPressed.takeUnless { shouldDispatchBackStack },
59-
onDismissRequest = {
60-
viewModel.code = ""
61-
viewModel.isConnectSheetVisible = false
62-
},
67+
channels = viewModel.channels,
6368
modifier = modifier
6469
)
6570
}
6671

6772
@Composable
6873
private fun AppImpl(
6974
navController: NavHostController,
70-
onBackPressed: (() -> Unit)?,
71-
onDismissRequest: () -> Unit,
75+
channels: Flow<PagingData<Channel>>,
7276
modifier: Modifier = Modifier
7377
) {
7478
val context = LocalContext.current
7579
val spacing = LocalSpacing.current
80+
val helper = LocalHelper.current
7681

7782
val zappingMode by preferenceOf(PreferencesKeys.ZAPPING_MODE)
7883
val remoteControl by preferenceOf(PreferencesKeys.REMOTE_CONTROL)
7984

8085
val entry by navController.currentBackStackEntryAsState()
8186

82-
val rootDestination by remember {
87+
val currentDestination by remember {
8388
derivedStateOf {
84-
Destination.Root.of(entry?.destination?.route)
89+
Destination.of(entry?.destination?.route)
8590
}
8691
}
8792

93+
val navigateToDestination = { destination: Destination ->
94+
navController.navigate(destination.name, navOptions {
95+
popUpTo(destination.name) {
96+
inclusive = true
97+
}
98+
})
99+
}
100+
88101
val navigateToChannel: () -> Unit = {
89102
if (!zappingMode || !PlayerActivity.isInPipMode) {
90103
val options = ActivityOptions.makeCustomAnimation(
@@ -99,37 +112,110 @@ private fun AppImpl(
99112
}
100113
}
101114

102-
Scaffold(
103-
rootDestination = rootDestination,
104-
onBackPressed = onBackPressed,
105-
navigateToChannel = navigateToChannel,
106-
navigateToRootDestination = {
107-
navController.navigate(it.name, navOptions {
108-
popUpTo(it.name) {
109-
inclusive = true
110-
}
111-
})
115+
NavigationSuiteScaffold(
116+
navigationSuiteItems = {
117+
Destination.entries.forEach { destination ->
118+
val isSelected = destination == currentDestination
119+
item(
120+
icon = {
121+
Icon(
122+
imageVector = when {
123+
isSelected -> destination.selectedIcon
124+
else -> destination.unselectedIcon
125+
},
126+
contentDescription = stringResource(destination.iconTextId)
127+
)
128+
},
129+
label = {
130+
Text(stringResource(destination.iconTextId))
131+
},
132+
selected = isSelected,
133+
onClick = { navigateToDestination(destination) },
134+
alwaysShowLabel = false
135+
)
136+
}
112137
},
113-
modifier = modifier.fillMaxSize()
114-
) { contentPadding ->
115-
AppNavHost(
116-
navController = navController,
117-
navigateToRootDestination = { navController.navigate(it.name) },
118-
navigateToChannel = navigateToChannel,
119-
contentPadding = contentPadding,
120-
modifier = Modifier.fillMaxSize()
121-
)
122-
// snack-host area
123-
Row(
124-
horizontalArrangement = Arrangement.spacedBy(spacing.small, Alignment.End),
125-
verticalAlignment = Alignment.Bottom,
126-
modifier = Modifier
127-
.fillMaxWidth()
128-
.align(Alignment.BottomCenter)
129-
.padding(contentPadding)
130-
.padding(spacing.medium)
131-
) {
132-
SnackHost(Modifier.weight(1f))
138+
modifier = modifier
139+
) {
140+
Column {
141+
val coroutineScope = rememberCoroutineScope()
142+
val searchBarState = rememberSearchBarState()
143+
val textFieldState = rememberTextFieldState()
144+
val inputField = @Composable {
145+
SearchBarDefaults.InputField(
146+
searchBarState = searchBarState,
147+
textFieldState = textFieldState,
148+
onSearch = { coroutineScope.launch { searchBarState.animateToCollapsed() } },
149+
placeholder = { Text("Search...") },
150+
leadingIcon = {
151+
if (searchBarState.currentValue == SearchBarValue.Expanded) {
152+
IconButton(
153+
onClick = { coroutineScope.launch { searchBarState.animateToCollapsed() } }
154+
) {
155+
Icon(
156+
Icons.AutoMirrored.Default.ArrowBack,
157+
contentDescription = "Back"
158+
)
159+
}
160+
} else {
161+
Icon(Icons.Default.Search, contentDescription = null)
162+
}
163+
},
164+
trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = null) },
165+
)
166+
}
167+
TopSearchBar(
168+
state = searchBarState,
169+
inputField = inputField
170+
)
171+
ExpandedFullScreenSearchBar(
172+
inputField = inputField,
173+
state = searchBarState
174+
) {
175+
BackHandler {
176+
coroutineScope.launch {
177+
searchBarState.animateToCollapsed()
178+
}
179+
}
180+
val state = rememberLazyStaggeredGridState()
181+
ChannelGallery(
182+
state = state,
183+
rowCount = 1,
184+
channels = channels,
185+
zapping = null,
186+
recently = false,
187+
isVodOrSeriesPlaylist = false,
188+
onClick = { channel ->
189+
coroutineScope.launch {
190+
helper.play(MediaCommand.Common(channel.id))
191+
navigateToChannel()
192+
}
193+
},
194+
onLongClick = {},
195+
getProgrammeCurrently = { null },
196+
reloadThumbnail = { null },
197+
syncThumbnail = { null },
198+
contentPadding = WindowInsets.ime.asPaddingValues()
199+
)
200+
}
201+
AppNavHost(
202+
navController = navController,
203+
navigateToDestination = { navController.navigate(it.name) },
204+
navigateToChannel = navigateToChannel,
205+
modifier = Modifier
206+
.fillMaxWidth()
207+
.weight(1f)
208+
)
209+
// snack-host area
210+
Row(
211+
horizontalArrangement = Arrangement.spacedBy(spacing.small, Alignment.End),
212+
verticalAlignment = Alignment.Bottom,
213+
modifier = Modifier
214+
.fillMaxWidth()
215+
.padding(spacing.medium)
216+
) {
217+
SnackHost(Modifier.weight(1f))
218+
}
133219
}
134220
}
135221
}

app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,52 @@ package com.m3u.smartphone.ui
33
import androidx.compose.runtime.getValue
44
import androidx.compose.runtime.mutableStateOf
55
import androidx.compose.runtime.setValue
6+
import androidx.compose.runtime.snapshotFlow
67
import androidx.lifecycle.ViewModel
78
import androidx.lifecycle.viewModelScope
9+
import androidx.paging.Pager
10+
import androidx.paging.PagingConfig
11+
import androidx.paging.PagingData
812
import androidx.work.WorkManager
13+
import com.m3u.data.database.model.Channel
14+
import com.m3u.data.repository.channel.ChannelRepository
915
import com.m3u.data.repository.playlist.PlaylistRepository
1016
import com.m3u.data.worker.SubscriptionWorker
1117
import dagger.hilt.android.lifecycle.HiltViewModel
18+
import kotlinx.coroutines.flow.Flow
19+
import kotlinx.coroutines.flow.emptyFlow
20+
import kotlinx.coroutines.flow.flatMapLatest
1221
import kotlinx.coroutines.launch
1322
import javax.inject.Inject
1423

1524
@HiltViewModel
1625
class AppViewModel @Inject constructor(
1726
private val playlistRepository: PlaylistRepository,
27+
private val channelRepository: ChannelRepository,
1828
private val workManager: WorkManager,
1929
) : ViewModel() {
2030
init {
2131
refreshProgrammes()
2232
}
2333

34+
val channels: Flow<PagingData<Channel>> = snapshotFlow { searchQuery.value }
35+
.flatMapLatest { query ->
36+
if (query.isBlank()) {
37+
emptyFlow()
38+
} else {
39+
Pager(
40+
config = PagingConfig(
41+
pageSize = 20,
42+
enablePlaceholders = false,
43+
prefetchDistance = 5
44+
),
45+
pagingSourceFactory = { channelRepository.search(query) }
46+
)
47+
.flow
48+
}
49+
}
50+
51+
var searchQuery = mutableStateOf("")
2452
private fun refreshProgrammes() {
2553
viewModelScope.launch {
2654
val playlists = playlistRepository.getAllAutoRefresh()

app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/HeadlineBackground.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ internal fun HeadlineBackground(modifier: Modifier = Modifier) {
4444
val darkMode by preferenceOf(PreferencesKeys.DARK_MODE)
4545
val followSystemTheme by preferenceOf(PreferencesKeys.FOLLOW_SYSTEM_THEME)
4646
val noPictureMode by preferenceOf(PreferencesKeys.NO_PICTURE_MODE)
47-
val colorfulBackground by preferenceOf(PreferencesKeys.COLORFUL_BACKGROUND)
48-
47+
4948
val isSystemInDarkTheme = isSystemInDarkTheme()
5049

5150
val useDarkTheme by remember {
@@ -70,7 +69,7 @@ internal fun HeadlineBackground(modifier: Modifier = Modifier) {
7069
animationSpec = tween(800)
7170
)
7271

73-
if (!noPictureMode && !colorfulBackground) {
72+
if (!noPictureMode) {
7473
AsyncImage(
7574
model = remember(url) {
7675
ImageRequest.Builder(context)

0 commit comments

Comments
 (0)