mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
add the browse ui: library tabs, detail screens, settings and server setup
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
<application
|
||||
android:name=".SubtracksApp"
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.subtracks.ui.HelloScreen
|
||||
import com.subtracks.ui.SubtracksRoot
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -13,7 +13,7 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
SubtracksTheme {
|
||||
HelloScreen()
|
||||
SubtracksRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
package com.subtracks
|
||||
|
||||
import android.app.Application
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import com.subtracks.di.appModule
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
|
||||
class SubtracksApp : Application()
|
||||
class SubtracksApp : Application() {
|
||||
private val http = OkHttpClient()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
stopKoin()
|
||||
startKoin {
|
||||
modules(appModule(this@SubtracksApp, http))
|
||||
}
|
||||
SingletonImageLoader.setSafe { context ->
|
||||
ImageLoader
|
||||
.Builder(context)
|
||||
.components { add(OkHttpNetworkFetcherFactory(callFactory = { http })) }
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.subtracks.ui
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.library.AlbumDetailRoute
|
||||
import com.subtracks.ui.library.LibraryRoute
|
||||
import com.subtracks.ui.library.PlaylistDetailRoute
|
||||
import com.subtracks.ui.settings.AddSourceRoute
|
||||
import com.subtracks.ui.settings.SettingsRoute
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
class RootViewModel(
|
||||
sourceRepository: SourceRepository,
|
||||
) : ViewModel() {
|
||||
val hasSource =
|
||||
sourceRepository
|
||||
.activeSourceId()
|
||||
.map { it != null }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
}
|
||||
|
||||
private const val NAVIGATION_DURATION_MS = 260
|
||||
|
||||
private object Routes {
|
||||
const val LIBRARY = "library"
|
||||
const val SETTINGS = "settings"
|
||||
const val ADD_SERVER = "add-server"
|
||||
const val ALBUM_DETAIL = "album/{albumId}"
|
||||
const val PLAYLIST_DETAIL = "playlist/{playlistId}"
|
||||
|
||||
fun album(id: String) = "album/${Uri.encode(id)}"
|
||||
|
||||
fun playlist(id: String) = "playlist/${Uri.encode(id)}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SubtracksRoot(root: RootViewModel = koinViewModel()) {
|
||||
when (val hasSource = root.hasSource.collectAsStateWithLifecycle().value) {
|
||||
null -> LoadingState()
|
||||
false -> AddSourceRoute(onSaved = {}, onBack = null)
|
||||
true -> MainNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainNavigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.LIBRARY,
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
||||
fadeIn(tween(NAVIGATION_DURATION_MS))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
||||
fadeOut(tween(NAVIGATION_DURATION_MS))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
||||
fadeIn(tween(NAVIGATION_DURATION_MS))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
||||
fadeOut(tween(NAVIGATION_DURATION_MS))
|
||||
},
|
||||
) {
|
||||
composable(Routes.LIBRARY) {
|
||||
LibraryRoute(
|
||||
onAlbumClick = { album -> navController.navigate(Routes.album(album.id)) },
|
||||
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
|
||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
)
|
||||
}
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsRoute(
|
||||
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.ALBUM_DETAIL,
|
||||
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
AlbumDetailRoute(
|
||||
albumId = entry.arguments?.getString("albumId").orEmpty(),
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.PLAYLIST_DETAIL,
|
||||
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
PlaylistDetailRoute(
|
||||
playlistId = entry.arguments?.getString("playlistId").orEmpty(),
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Routes.ADD_SERVER) {
|
||||
AddSourceRoute(
|
||||
onSaved = { navController.popBackStack() },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.subtracks.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
|
||||
@Composable
|
||||
fun HelloScreen(modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Subtracks",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Kotlin / Jetpack Compose rewrite",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun HelloScreenPreview() {
|
||||
SubtracksTheme {
|
||||
HelloScreen()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
|
||||
@Composable
|
||||
fun CoverArt(
|
||||
ref: CoverArtRef?,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Text(
|
||||
text = name.trim().take(1).uppercase(),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
if (ref != null) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalPlatformContext.current)
|
||||
.data(ref.url)
|
||||
.memoryCacheKey(ref.cacheKey)
|
||||
.diskCacheKey(ref.cacheKey)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingState(modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun AlbumDetailRoute(
|
||||
albumId: String,
|
||||
onBack: () -> Unit,
|
||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||
) {
|
||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
AlbumDetailScreen(
|
||||
album = album,
|
||||
songs = songs,
|
||||
coverArt = viewModel::coverArt,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlbumDetailScreen(
|
||||
album: Album?,
|
||||
songs: List<Song>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(album?.name.orEmpty(), maxLines = 1) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(album?.coverArt, false),
|
||||
name = album?.name.orEmpty(),
|
||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)),
|
||||
)
|
||||
Column {
|
||||
Text(album?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = album?.albumArtist.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = listOfNotNull(album?.year?.takeIf { it > 0 }, "${songs.size} songs").joinToString(" • "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(songs, key = { it.id }) { song -> SongRow(song) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AlbumDetailViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
albumId: String,
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
|
||||
val songs: Flow<List<Song>> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) }
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
|
||||
const val ALBUM_COVER_TAG = "album-cover"
|
||||
|
||||
@Composable
|
||||
fun AlbumsContent(
|
||||
items: LazyPagingItems<Album>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
bottomInset: Dp,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
LoadingState(modifier)
|
||||
}
|
||||
|
||||
items.itemCount == 0 -> {
|
||||
EmptyState("No albums yet.\nSync with your server to fill your library.", modifier)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val album = items[index]
|
||||
if (album != null) {
|
||||
CoverArt(
|
||||
ref = coverArt(album.coverArt, true),
|
||||
name = album.name,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.clickable { onAlbumClick(album) }
|
||||
.testTag(ALBUM_COVER_TAG),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
|
||||
@Composable
|
||||
fun ArtistsContent(
|
||||
items: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
bottomInset: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
LoadingState(modifier)
|
||||
}
|
||||
|
||||
items.itemCount == 0 -> {
|
||||
EmptyState("No artists yet.", modifier)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val artist = items[index]
|
||||
if (artist != null) {
|
||||
ListItem(
|
||||
headlineContent = { Text(artist.name) },
|
||||
supportingContent = { Text("${artist.albumCount} albums") },
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
|
||||
import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.MusicNote
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.Sync
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.boundsInParent
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class LibraryTab(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Albums("Albums", Icons.Rounded.Album),
|
||||
Artists("Artists", Icons.Rounded.Person),
|
||||
Songs("Songs", Icons.Rounded.MusicNote),
|
||||
Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryRoute(
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
viewModel: LibraryViewModel = koinViewModel(),
|
||||
) {
|
||||
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||
LibraryScreen(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { selectedTab = it },
|
||||
syncing = syncing,
|
||||
albums = viewModel.albums.collectAsLazyPagingItems(),
|
||||
artists = viewModel.artists.collectAsLazyPagingItems(),
|
||||
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||
playlists = viewModel.playlists.collectAsLazyPagingItems(),
|
||||
coverArt = viewModel::coverArt,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSync = viewModel::sync,
|
||||
onOpenSettings = onOpenSettings,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
selectedTab: LibraryTab,
|
||||
onTabSelected: (LibraryTab) -> Unit,
|
||||
albums: LazyPagingItems<Album>,
|
||||
artists: LazyPagingItems<Artist>,
|
||||
songs: LazyPagingItems<SongListItem>,
|
||||
playlists: LazyPagingItems<Playlist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
syncing: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pagerState =
|
||||
rememberPagerState(
|
||||
initialPage = selectedTab.ordinal,
|
||||
pageCount = { LibraryTab.entries.size },
|
||||
)
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||
onTabSelected(LibraryTab.entries[page])
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (pagerState.currentPage != selectedTab.ordinal) {
|
||||
pagerState.animateScrollToPage(selectedTab.ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val titleHeight =
|
||||
with(density) {
|
||||
MaterialTheme.typography.headlineLarge.lineHeight
|
||||
.toDp()
|
||||
} + 12.dp
|
||||
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||
SideEffect { scrollBehavior.state.heightOffsetLimit = -with(density) { titleHeight.toPx() } }
|
||||
val titleFraction = scrollBehavior.state.collapsedFraction
|
||||
|
||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val bottomInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = statusBarTop),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(titleHeight * (1f - titleFraction))
|
||||
.clipToBounds(),
|
||||
) {
|
||||
Text(
|
||||
text = selectedTab.label,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.offset(y = -(titleHeight * titleFraction))
|
||||
.wrapContentHeight(unbounded = true)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
LibraryTabs(
|
||||
pagerState = pagerState,
|
||||
onTabSelected = onTabSelected,
|
||||
syncing = syncing,
|
||||
onSync = onSync,
|
||||
onOpenSettings = onOpenSettings,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
) { page ->
|
||||
when (LibraryTab.entries[page]) {
|
||||
LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick)
|
||||
LibraryTab.Artists -> ArtistsContent(artists, coverArt, bottomInset)
|
||||
LibraryTab.Songs -> SongsContent(songs, coverArt, bottomInset)
|
||||
LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, bottomInset, onPlaylistClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LibraryTabs(
|
||||
pagerState: PagerState,
|
||||
onTabSelected: (LibraryTab) -> Unit,
|
||||
syncing: Boolean,
|
||||
onSync: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
val iconFadeThreshold = 0.65f
|
||||
val indicatorStretch = 18.dp
|
||||
val density = LocalDensity.current
|
||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||
val page = pagerState.currentPage
|
||||
val fraction = pagerState.currentPageOffsetFraction
|
||||
val position = page + fraction
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
bounds[page]?.let { current ->
|
||||
val neighbour = bounds[if (fraction >= 0f) page + 1 else page - 1]
|
||||
val step = if (neighbour != null) abs(fraction) else 0f
|
||||
val left = current.left + ((neighbour?.left ?: current.left) - current.left) * step
|
||||
val right = current.right + ((neighbour?.right ?: current.right) - current.right) * step
|
||||
val stretch = with(density) { indicatorStretch.toPx() } * abs(fraction) / 2f
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset { IntOffset((left - stretch).roundToInt(), current.top.roundToInt()) }
|
||||
.width(with(density) { (right - left + stretch * 2f).toDp() })
|
||||
.height(with(density) { current.height.toDp() })
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.onBackground),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
progress =
|
||||
((iconFadeThreshold - abs(position - index)) / iconFadeThreshold)
|
||||
.coerceIn(0f, 1f),
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(end = 4.dp).size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onSync) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Sync,
|
||||
contentDescription = "Sync",
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = "Settings",
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TabButton(
|
||||
tab: LibraryTab,
|
||||
progress: Float,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val content = lerp(MaterialTheme.colorScheme.onBackground, MaterialTheme.colorScheme.background, progress)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = tab.icon,
|
||||
contentDescription = tab.label,
|
||||
tint = content,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val syncManager: SyncManager,
|
||||
userPreferences: UserPreferences,
|
||||
) : ViewModel() {
|
||||
val albums: Flow<PagingData<Album>> =
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
userPreferences.albumSort.flatMapLatest { sort -> libraryRepository.albums(sourceId, sort) }
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
val artists: Flow<PagingData<Artist>> =
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { libraryRepository.artists(it) }
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
val songs: Flow<PagingData<SongListItem>> =
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { libraryRepository.songs(it) }
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
val playlists: Flow<PagingData<Playlist>> =
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { libraryRepository.playlists(it) }
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
val syncing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it == SyncStatus.Running }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun sync() = syncManager.requestSync()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun PlaylistDetailRoute(
|
||||
playlistId: String,
|
||||
onBack: () -> Unit,
|
||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||
) {
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||
PlaylistDetailScreen(
|
||||
playlist = playlist,
|
||||
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||
coverArt = viewModel::coverArt,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaylistDetailScreen(
|
||||
playlist: Playlist?,
|
||||
songs: LazyPagingItems<SongListItem>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(playlist?.name.orEmpty(), maxLines = 1) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(playlist?.coverArt, false),
|
||||
name = playlist?.name.orEmpty(),
|
||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)),
|
||||
)
|
||||
Column {
|
||||
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = "${playlist?.songCount ?: 0} songs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||
val item = songs[index]
|
||||
if (item != null) {
|
||||
SongRow(song = item.song, coverArtId = item.coverArt, coverArt = coverArt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PlaylistDetailViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
playlistId: String,
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
|
||||
val songs: Flow<PagingData<SongListItem>> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.playlistSongs(it, playlistId) }
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
|
||||
@Composable
|
||||
fun PlaylistsContent(
|
||||
items: LazyPagingItems<Playlist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
bottomInset: Dp,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
LoadingState(modifier)
|
||||
}
|
||||
|
||||
items.itemCount == 0 -> {
|
||||
EmptyState("No playlists yet.", modifier)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val playlist = items[index]
|
||||
if (playlist != null) {
|
||||
ListItem(
|
||||
headlineContent = { Text(playlist.name) },
|
||||
supportingContent = {
|
||||
Text(playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs")
|
||||
},
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(playlist.coverArt, true),
|
||||
name = playlist.name,
|
||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onPlaylistClick(playlist) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
|
||||
@Composable
|
||||
fun SongsContent(
|
||||
items: LazyPagingItems<SongListItem>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
bottomInset: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
LoadingState(modifier)
|
||||
}
|
||||
|
||||
items.itemCount == 0 -> {
|
||||
EmptyState("No songs yet.", modifier)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
|
||||
val item = items[index]
|
||||
if (item != null) {
|
||||
SongRow(song = item.song, coverArtId = item.coverArt, coverArt = coverArt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SongRow(
|
||||
song: Song,
|
||||
coverArtId: String? = null,
|
||||
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
leadingContent =
|
||||
if (coverArt != null) {
|
||||
{
|
||||
CoverArt(
|
||||
ref = coverArt(coverArtId, true),
|
||||
name = song.album ?: song.title,
|
||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
headlineContent = { Text(song.title) },
|
||||
supportingContent = {
|
||||
val subtitle = listOfNotNull(song.artist, song.album).joinToString(" • ")
|
||||
if (subtitle.isNotEmpty()) Text(subtitle)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.subtracks.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun AddSourceRoute(
|
||||
onSaved: () -> Unit,
|
||||
onBack: (() -> Unit)?,
|
||||
viewModel: AddSourceViewModel = koinViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
AddSourceScreen(
|
||||
state = state,
|
||||
onNameChange = viewModel::setName,
|
||||
onAddressChange = viewModel::setAddress,
|
||||
onUsernameChange = viewModel::setUsername,
|
||||
onPasswordChange = viewModel::setPassword,
|
||||
onTokenAuthChange = viewModel::setTokenAuth,
|
||||
onTest = viewModel::testConnection,
|
||||
onSave = { viewModel.save(onSaved) },
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddSourceScreen(
|
||||
state: AddSourceState,
|
||||
onNameChange: (String) -> Unit,
|
||||
onAddressChange: (String) -> Unit,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onTokenAuthChange: (Boolean) -> Unit,
|
||||
onTest: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onBack: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Add server") },
|
||||
navigationIcon = {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (state.busy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
OutlinedButton(onClick = onTest, enabled = !state.busy) {
|
||||
Text("Test connection")
|
||||
}
|
||||
Button(onClick = onSave, enabled = !state.busy) {
|
||||
Text("Save and sync")
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Connect a Subsonic-compatible server such as Navidrome, gonic or Airsonic.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.address,
|
||||
onValueChange = onAddressChange,
|
||||
label = { Text("Server address") },
|
||||
placeholder = { Text("https://music.example.com") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = onUsernameChange,
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Token authentication")
|
||||
Text(
|
||||
"Recommended. Disable only for servers that reject the salted token scheme.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = state.useTokenAuth, onCheckedChange = onTokenAuthChange)
|
||||
}
|
||||
if (state.message != null) {
|
||||
Text(
|
||||
text = state.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (state.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.subtracks.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AddSourceState(
|
||||
val name: String = "",
|
||||
val address: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val useTokenAuth: Boolean = true,
|
||||
val busy: Boolean = false,
|
||||
val message: String? = null,
|
||||
val isError: Boolean = false,
|
||||
)
|
||||
|
||||
class AddSourceViewModel(
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val syncManager: SyncManager,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AddSourceState())
|
||||
val state: StateFlow<AddSourceState> = _state
|
||||
|
||||
fun setName(value: String) = _state.update { it.copy(name = value) }
|
||||
|
||||
fun setAddress(value: String) = _state.update { it.copy(address = value) }
|
||||
|
||||
fun setUsername(value: String) = _state.update { it.copy(username = value) }
|
||||
|
||||
fun setPassword(value: String) = _state.update { it.copy(password = value) }
|
||||
|
||||
fun setTokenAuth(value: Boolean) = _state.update { it.copy(useTokenAuth = value) }
|
||||
|
||||
fun testConnection() {
|
||||
_state.update { it.copy(busy = true, message = null) }
|
||||
val current = _state.value
|
||||
viewModelScope.launch {
|
||||
val result = sourceRepository.ping(current.address, current.username, current.password, current.useTokenAuth)
|
||||
_state.update {
|
||||
it.copy(
|
||||
busy = false,
|
||||
message = result.fold({ "Connection OK" }, { error -> "Failed: ${error.message}" }),
|
||||
isError = result.isFailure,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(onSaved: () -> Unit) {
|
||||
val current = _state.value
|
||||
if (current.name.isBlank() || current.address.isBlank()) {
|
||||
_state.update { it.copy(message = "Name and address are required", isError = true) }
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(busy = true, message = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
sourceRepository.addSource(
|
||||
name = current.name,
|
||||
address = current.address,
|
||||
username = current.username,
|
||||
password = current.password,
|
||||
useTokenAuth = current.useTokenAuth,
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
syncManager.requestSync()
|
||||
_state.update { it.copy(busy = false) }
|
||||
onSaved()
|
||||
},
|
||||
onFailure = { error ->
|
||||
_state.update {
|
||||
it.copy(busy = false, message = "Could not save: ${error.message}", isError = true)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.subtracks.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.data.model.Source
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun SettingsRoute(
|
||||
onAddServer: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: SettingsViewModel = koinViewModel(),
|
||||
) {
|
||||
val sources by viewModel.sources.collectAsStateWithLifecycle()
|
||||
val activeSourceId by viewModel.activeSourceId.collectAsStateWithLifecycle()
|
||||
SettingsScreen(
|
||||
sources = sources,
|
||||
activeSourceId = activeSourceId,
|
||||
onSelectSource = viewModel::selectSource,
|
||||
onDeleteSource = viewModel::deleteSource,
|
||||
onAddServer = onAddServer,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
sources: List<Source>,
|
||||
activeSourceId: Long?,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onDeleteSource: (Long) -> Unit,
|
||||
onAddServer: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
||||
item { SectionHeader("Servers") }
|
||||
items(sources.size, key = { sources[it].id }) { index ->
|
||||
val source = sources[index]
|
||||
ListItem(
|
||||
headlineContent = { Text(source.name) },
|
||||
supportingContent = {
|
||||
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
leadingContent = {
|
||||
RadioButton(
|
||||
selected = source.id == activeSourceId,
|
||||
onClick = { onSelectSource(source.id) },
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { onDeleteSource(source.id) }) {
|
||||
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}")
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = onAddServer) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add server")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.subtracks.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(
|
||||
private val sourceRepository: SourceRepository,
|
||||
) : ViewModel() {
|
||||
val sources: StateFlow<List<Source>> =
|
||||
sourceRepository.sources().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val activeSourceId: StateFlow<Long?> =
|
||||
sourceRepository.activeSourceId().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
fun selectSource(id: Long) {
|
||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||
}
|
||||
|
||||
fun deleteSource(id: Long) {
|
||||
viewModelScope.launch { sourceRepository.deleteSource(id) }
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,9 @@ package com.subtracks.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650A4)
|
||||
val PurpleGrey40 = Color(0xFF625B71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
val Black = Color(0xFF000000)
|
||||
val NearBlack = Color(0xFF0A0A0A)
|
||||
val DarkGrey = Color(0xFF1A1A1A)
|
||||
val MidGrey = Color(0xFF2C2C2C)
|
||||
val LightGrey = Color(0xFF9E9E9E)
|
||||
val White = Color(0xFFFFFFFF)
|
||||
|
||||
@@ -4,16 +4,37 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val SubtracksDarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
)
|
||||
private val SubtracksColorScheme =
|
||||
darkColorScheme(
|
||||
primary = White,
|
||||
onPrimary = Black,
|
||||
primaryContainer = White,
|
||||
onPrimaryContainer = Black,
|
||||
secondary = White,
|
||||
onSecondary = Black,
|
||||
secondaryContainer = White,
|
||||
onSecondaryContainer = Black,
|
||||
tertiary = White,
|
||||
onTertiary = Black,
|
||||
background = Black,
|
||||
onBackground = White,
|
||||
surface = Black,
|
||||
onSurface = White,
|
||||
surfaceVariant = DarkGrey,
|
||||
onSurfaceVariant = LightGrey,
|
||||
surfaceContainerLowest = Black,
|
||||
surfaceContainerLow = NearBlack,
|
||||
surfaceContainer = NearBlack,
|
||||
surfaceContainerHigh = DarkGrey,
|
||||
surfaceContainerHighest = MidGrey,
|
||||
outline = MidGrey,
|
||||
outlineVariant = DarkGrey,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SubtracksTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = SubtracksDarkColorScheme,
|
||||
colorScheme = SubtracksColorScheme,
|
||||
typography = SubtracksTypography,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -3,10 +3,11 @@ package com.subtracks.ui.theme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
val SubtracksTypography = Typography().let { base ->
|
||||
base.copy(
|
||||
headlineLarge = base.headlineLarge.copy(fontWeight = FontWeight.W800),
|
||||
headlineMedium = base.headlineMedium.copy(fontWeight = FontWeight.W700),
|
||||
headlineSmall = base.headlineSmall.copy(fontWeight = FontWeight.W600),
|
||||
)
|
||||
}
|
||||
val SubtracksTypography =
|
||||
Typography().let { base ->
|
||||
base.copy(
|
||||
headlineLarge = base.headlineLarge.copy(fontWeight = FontWeight.W800),
|
||||
headlineMedium = base.headlineMedium.copy(fontWeight = FontWeight.W700),
|
||||
headlineSmall = base.headlineSmall.copy(fontWeight = FontWeight.W600),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user