add the browse ui: library tabs, detail screens, settings and server setup

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 3f91a29850
commit 87cab9d805
23 changed files with 1640 additions and 76 deletions
+1
View File
@@ -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),
)
}