v0.1.0-Y (vc=13): VideoDetail reorder + home search pill + status-bar padding
VideoDetail card order (top → bottom under the player thumbnail): Recommended → More from <uploader> → Video details NewPipeExtractor fan-out: streamInfo brings 'related', and a parallel ChannelInfo+VIDEOS-tab fetch brings 'moreFromChannel' (filtered to drop the current video). Same data shape as feed/search rows; reuses RelatedRow. Home top bar gets a YouTube-style search pill in the title slot — tap takes you to the search screen. The drawer 'Search' entry is gone (the pill replaces it). Section header below the top bar — 'Latest from your subs' / 'History' — makes which view you're on obvious. Empty subs state is friendlier. Status-bar padding (statusBarsPadding) added to VideoDetailScreen, SearchScreen, ChannelScreen, SettingsScreen — fixes content rendering under the Pixel camera cutout in edge-to-edge mode.
This commit is contained in:
parent
9ad3302f52
commit
94ef84f1ac
7 changed files with 145 additions and 47 deletions
|
|
@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
|
||||||
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// Sulkta fork — Straw
|
||||||
const val STRAW_VERSION_CODE = 12
|
const val STRAW_VERSION_CODE = 13
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-X"
|
const val STRAW_VERSION_NAME = "0.1.0-Y"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,11 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
|
@ -122,16 +126,6 @@ fun StrawHome(
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
)
|
)
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||||
NavigationDrawerItem(
|
|
||||||
label = { Text("Search") },
|
|
||||||
icon = { Text("🔍") },
|
|
||||||
selected = false,
|
|
||||||
onClick = {
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
onOpenSearch()
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
)
|
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
label = { Text("Settings") },
|
label = { Text("Settings") },
|
||||||
icon = { Text("⚙") },
|
icon = { Text("⚙") },
|
||||||
|
|
@ -149,13 +143,34 @@ fun StrawHome(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
// Search-pill in the title slot — tap takes you to the
|
||||||
text = when (view) {
|
// full search screen with the field auto-focused. Same
|
||||||
HomeView.Subs -> "Subscriptions"
|
// idea as YT's mobile top bar.
|
||||||
HomeView.History -> "History"
|
Surface(
|
||||||
},
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.titleLarge,
|
.fillMaxWidth()
|
||||||
)
|
.padding(end = 8.dp)
|
||||||
|
.height(40.dp)
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.clickable(onClick = onOpenSearch),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"🔍",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
"Search YouTube",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||||
|
|
@ -171,6 +186,16 @@ fun StrawHome(
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
|
// Section header — clarifies which view is current.
|
||||||
|
Text(
|
||||||
|
text = when (view) {
|
||||||
|
HomeView.Subs -> "Latest from your subs"
|
||||||
|
HomeView.History -> "History"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
when (view) {
|
when (view) {
|
||||||
HomeView.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm)
|
HomeView.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm)
|
||||||
HomeView.History -> HistoryPane(onOpenVideo)
|
HomeView.History -> HistoryPane(onOpenVideo)
|
||||||
|
|
@ -222,10 +247,16 @@ private fun SubsPane(
|
||||||
Column {
|
Column {
|
||||||
if (subs.isEmpty()) {
|
if (subs.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"No subscriptions yet. Tap Search in the menu, find a channel, and Subscribe.",
|
"No subscriptions yet.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"Tap the search bar above, find a channel, and Subscribe.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
return@Column
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -63,18 +64,18 @@ fun ChannelScreen(
|
||||||
|
|
||||||
when {
|
when {
|
||||||
state.loading -> Box(
|
state.loading -> Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) { CircularProgressIndicator() }
|
) { CircularProgressIndicator() }
|
||||||
|
|
||||||
state.error != null -> Box(
|
state.error != null -> Box(
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text("error: ${state.error}", color = MaterialTheme.colorScheme.error)
|
Text("error: ${state.error}", color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
else -> LazyColumn(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
|
||||||
item {
|
item {
|
||||||
state.banner?.let { b ->
|
state.banner?.let { b ->
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -74,6 +75,7 @@ fun VideoDetailScreen(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
|
|
@ -121,7 +123,40 @@ fun VideoDetailScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// ── Recommended ──────────────────────────────────────────
|
||||||
|
if (d.related.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Recommended",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
d.related.take(20).forEach { rel ->
|
||||||
|
RelatedRow(rel) { onOpenVideo(rel.url, rel.title) }
|
||||||
|
androidx.compose.material3.HorizontalDivider()
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── More from <uploader> ─────────────────────────────────
|
||||||
|
if (d.moreFromChannel.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
if (d.uploader.isBlank()) "More from this channel"
|
||||||
|
else "More from ${d.uploader}",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
d.moreFromChannel.take(20).forEach { item ->
|
||||||
|
RelatedRow(item) { onOpenVideo(item.url, item.title) }
|
||||||
|
androidx.compose.material3.HorizontalDivider()
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Video details ────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = d.title,
|
text = d.title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
|
@ -189,6 +224,15 @@ fun VideoDetailScreen(
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
// AUD-MED: cap input length before regex passes — defends
|
||||||
|
// against ANR on multi-MB descriptions.
|
||||||
|
Text(
|
||||||
|
text = stripHtml(d.description.take(20_000)).take(2000),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
|
||||||
if (showDownloadDialog) {
|
if (showDownloadDialog) {
|
||||||
val info = state.streamInfo
|
val info = state.streamInfo
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|
@ -249,28 +293,6 @@ fun VideoDetailScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
// AUD-MED: cap input length before regex passes — defends
|
|
||||||
// against ANR on multi-MB descriptions.
|
|
||||||
Text(
|
|
||||||
text = stripHtml(d.description.take(20_000)).take(2000),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (d.related.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
Text(
|
|
||||||
"Related",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
d.related.take(20).forEach { rel ->
|
|
||||||
RelatedRow(rel) { onOpenVideo(rel.url, rel.title) }
|
|
||||||
androidx.compose.material3.HorizontalDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
|
import org.schabi.newpipe.extractor.ServiceList
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||||
|
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
|
||||||
|
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
|
||||||
|
|
@ -34,6 +39,9 @@ data class VideoDetail(
|
||||||
val ryd: RydVotes? = null,
|
val ryd: RydVotes? = null,
|
||||||
val sbSegmentCount: Int = 0,
|
val sbSegmentCount: Int = 0,
|
||||||
val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
|
val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
|
||||||
|
/** Other videos from the same channel — separate from related (which is YT's
|
||||||
|
* algo). Anchored to the uploader the user chose; matches the sub-feed ethos. */
|
||||||
|
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class VideoDetailUiState(
|
data class VideoDetailUiState(
|
||||||
|
|
@ -98,6 +106,38 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
)
|
)
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
|
|
||||||
|
// More from this channel — anchored to the uploader the user
|
||||||
|
// already chose. Best-effort: empty if the fetch fails so the
|
||||||
|
// detail screen still renders. Filters out the current video.
|
||||||
|
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> =
|
||||||
|
if (info.uploaderUrl.isNullOrBlank()) emptyList()
|
||||||
|
else withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
||||||
|
val ch = ChannelInfo.getInfo(service, info.uploaderUrl)
|
||||||
|
val videosTab = ch.tabs.firstOrNull {
|
||||||
|
it.contentFilters.contains(ChannelTabs.VIDEOS)
|
||||||
|
} ?: ch.tabs.firstOrNull()
|
||||||
|
if (videosTab == null) emptyList()
|
||||||
|
else ChannelTabInfo.getInfo(service, videosTab)
|
||||||
|
.relatedItems
|
||||||
|
.filterIsInstance<StreamInfoItem>()
|
||||||
|
.filter { it.url != streamUrl }
|
||||||
|
.take(20)
|
||||||
|
.map { si ->
|
||||||
|
com.sulkta.straw.feature.search.StreamItem(
|
||||||
|
url = si.url,
|
||||||
|
title = si.name ?: "(no title)",
|
||||||
|
uploader = si.uploaderName ?: uploader,
|
||||||
|
uploaderUrl = si.uploaderUrl ?: info.uploaderUrl,
|
||||||
|
thumbnail = bestThumbnail(si.thumbnails),
|
||||||
|
durationSeconds = si.duration,
|
||||||
|
viewCount = si.viewCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
_ui.value = VideoDetailUiState(
|
_ui.value = VideoDetailUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
detail = VideoDetail(
|
detail = VideoDetail(
|
||||||
|
|
@ -111,6 +151,7 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
ryd = ryd,
|
ryd = ryd,
|
||||||
sbSegmentCount = sbCount,
|
sbSegmentCount = sbCount,
|
||||||
related = related,
|
related = related,
|
||||||
|
moreFromChannel = moreFromChannel,
|
||||||
),
|
),
|
||||||
streamInfo = info,
|
streamInfo = info,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
|
@ -54,7 +55,7 @@ fun SearchScreen(
|
||||||
val state by vm.ui.collectAsStateWithLifecycle()
|
val state by vm.ui.collectAsStateWithLifecycle()
|
||||||
val recentSearches by History.get().searches.collectAsState()
|
val recentSearches by History.get().searches.collectAsState()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.query,
|
value = state.query,
|
||||||
onValueChange = vm::onQueryChange,
|
onValueChange = vm::onQueryChange,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -43,6 +44,7 @@ fun SettingsScreen() {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue