diff --git a/app/build.gradle b/app/build.gradle index 9bf1577..21a485e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,8 +47,8 @@ android { applicationId "io.musicorum.mobile" minSdk 28 targetSdk 34 - versionCode 69 - versionName "1.24" + versionCode 70 + versionName "2.0" //compileSdkPreview = "UpsideDownCake" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -170,10 +170,11 @@ dependencies { implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' - implementation 'androidx.activity:activity-compose:1.8.0-rc01' + implementation 'androidx.activity:activity-compose:1.8.2' + implementation 'androidx.core:core-splashscreen:1.0.0' implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui-tooling-preview" - implementation 'androidx.compose.material3:material3:1.2.0-alpha11' + implementation 'androidx.compose.material3:material3:1.2.0-beta01' implementation 'com.google.accompanist:accompanist-placeholder-material:0.26.5-rc' implementation 'io.coil-kt:coil-compose:2.4.0' implementation 'androidx.datastore:datastore-preferences:1.0.0' diff --git a/app/src/debug/res/drawable/splash_background.xml b/app/src/debug/res/drawable/splash_background.xml index 0617877..cc43a89 100644 --- a/app/src/debug/res/drawable/splash_background.xml +++ b/app/src/debug/res/drawable/splash_background.xml @@ -12,7 +12,7 @@ \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a53e1ea..3e66210 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,7 +36,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:testOnly="false" - android:theme="@style/Theme.Musicorum" + android:theme="@style/Theme.Musicorum.Starting" tools:targetApi="31"> diff --git a/app/src/main/java/io/musicorum/mobile/MainActivity.kt b/app/src/main/java/io/musicorum/mobile/MainActivity.kt index e0d3cf1..98c6db2 100644 --- a/app/src/main/java/io/musicorum/mobile/MainActivity.kt +++ b/app/src/main/java/io/musicorum/mobile/MainActivity.kt @@ -8,35 +8,31 @@ import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.CubicBezierEasing 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.slideInVertically import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat -import androidx.core.view.WindowCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.stringPreferencesKey @@ -49,7 +45,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.crowdin.platform.Crowdin -import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.android.gms.common.ConnectionResult.SERVICE_MISSING import com.google.android.gms.common.GoogleApiAvailability import com.google.android.play.core.appupdate.AppUpdateManagerFactory @@ -68,10 +63,9 @@ import io.musicorum.mobile.datastore.UserData import io.musicorum.mobile.ktor.endpoints.UserEndpoint import io.musicorum.mobile.models.FetchPeriod import io.musicorum.mobile.repositories.LocalUserRepository -import io.musicorum.mobile.router.BottomNavBar import io.musicorum.mobile.ui.theme.KindaBlack +import io.musicorum.mobile.ui.theme.LighterGray import io.musicorum.mobile.ui.theme.MusicorumMobileTheme -import io.musicorum.mobile.utils.CrowdinUtils import io.musicorum.mobile.utils.LocalSnackbar import io.musicorum.mobile.utils.LocalSnackbarContext import io.musicorum.mobile.utils.MessagingService @@ -81,6 +75,7 @@ import io.musicorum.mobile.views.RecentScrobbles import io.musicorum.mobile.views.charts.BaseChartDetail import io.musicorum.mobile.views.charts.Charts import io.musicorum.mobile.views.collage.Collage +import io.musicorum.mobile.views.friendlist.FriendList import io.musicorum.mobile.views.home.Home import io.musicorum.mobile.views.individual.Album import io.musicorum.mobile.views.individual.AlbumTracklist @@ -88,7 +83,7 @@ import io.musicorum.mobile.views.individual.Artist import io.musicorum.mobile.views.individual.PartialAlbum import io.musicorum.mobile.views.individual.TagScreen import io.musicorum.mobile.views.individual.Track -import io.musicorum.mobile.views.individual.User +import io.musicorum.mobile.views.individual.user.User import io.musicorum.mobile.views.login.loginGraph import io.musicorum.mobile.views.mostListened.MostListened import io.musicorum.mobile.views.scrobbling.Scrobbling @@ -130,7 +125,7 @@ class MainActivity : ComponentActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() + installSplashScreen() super.onCreate(savedInstanceState) val remoteConfig: FirebaseRemoteConfig = Firebase.remoteConfig @@ -171,7 +166,6 @@ class MainActivity : ComponentActivity() { } } - CrowdinUtils.initCrowdin(applicationContext) askNotificationPermission() MessagingService.createNotificationChannel(applicationContext) @@ -181,7 +175,7 @@ class MainActivity : ComponentActivity() { GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(this) } - WindowCompat.setDecorFitsSystemWindows(window, true) + //WindowCompat.setDecorFitsSystemWindows(window, true) firebaseAnalytics = Firebase.analytics /*window.setFlags( @@ -206,12 +200,10 @@ class MainActivity : ComponentActivity() { } setContent { - val useDarkIcons = !isSystemInDarkTheme() navController = rememberNavController().withSentryObservableEffect() val ctx = LocalContext.current val snackHostState = remember { SnackbarHostState() } - val systemUiController = rememberSystemUiController() if (intent?.data == null) { LaunchedEffect(Unit) { @@ -235,18 +227,14 @@ class MainActivity : ComponentActivity() { } - DisposableEffect(systemUiController, useDarkIcons) { - systemUiController.setNavigationBarColor(Color.Transparent) - systemUiController.setSystemBarsColor(KindaBlack) - - onDispose { } - } - val bottomBarDestinations = listOf("home", "scrobbling", "profile", "charts", "discover") - val showNav = - navController.currentBackStackEntryAsState().value?.destination?.route in bottomBarDestinations + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(KindaBlack.toArgb()), + navigationBarStyle = SystemBarStyle.dark(LighterGray.toArgb()), + ) + val navStackBackEntry by navController.currentBackStackEntryAsState() CompositionLocalProvider( LocalSnackbar provides LocalSnackbarContext(snackHostState), @@ -254,191 +242,184 @@ class MainActivity : ComponentActivity() { LocalAnalytics provides firebaseAnalytics ) { MusicorumMobileTheme { - Scaffold( - bottomBar = { - AnimatedVisibility( - visible = showNav, - enter = slideInVertically(initialOffsetY = { it }), - exit = slideOutVertically(targetOffsetY = { it }), - ) { - BottomNavBar(nav = navController) - } - }, - snackbarHost = { SnackbarHost(hostState = snackHostState) } - ) { pv -> - Surface( - Modifier - .fillMaxSize() - .padding(pv) - ) { - val animationCurve = CubicBezierEasing(0.76f, 0f, 0.24f, 1f) - val newRoute = navController.currentBackStackEntry?.destination?.route - NavHost( - navController = navController, - startDestination = "home", - enterTransition = { - if (newRoute in bottomBarDestinations) { - fadeIn(tween(200)) - } else { - slideInHorizontally(tween(350)) { fullWidth -> fullWidth } - } - }, - exitTransition = { - if (newRoute in bottomBarDestinations) { - fadeOut(tween(200)) - } else { - slideOutHorizontally(tween(350)) { fullWidth -> -fullWidth / 2 } - } - }, - popExitTransition = { - slideOutHorizontally( + Surface( + Modifier + .safeDrawingPadding() + .fillMaxSize() + ) { + val animationCurve = CubicBezierEasing(0.76f, 0f, 0.24f, 1f) + val destination = navStackBackEntry?.destination?.route + NavHost( + navController = navController, + startDestination = "home", + enterTransition = { + if (destination in bottomBarDestinations) { + EnterTransition.None + } else { + slideInHorizontally(tween(350)) { fullWidth -> fullWidth } + } + }, + exitTransition = { + if (destination in bottomBarDestinations) { + ExitTransition.None + } else { + slideOutHorizontally(tween(350)) { fullWidth -> -fullWidth / 2 } + } + }, + popExitTransition = { + slideOutHorizontally( + tween( + 500, + easing = animationCurve + ) + ) { fullWidth -> fullWidth } + + }, + popEnterTransition = { + if (destination in bottomBarDestinations) { + fadeIn(tween(200)) + } else { + slideInHorizontally( tween( 500, easing = animationCurve ) - ) { fullWidth -> fullWidth } - - }, - popEnterTransition = { - if (newRoute in bottomBarDestinations) { - fadeIn(tween(200)) - } else { - slideInHorizontally( - tween( - 500, - easing = animationCurve - ) - ) - } - } - ) { - /* WIP val views = - Reflections("io.musicorum.mobile") - .getMethodsAnnotatedWith(Route::class.java) - Log.d("REGISTRY", "${views.size} routes were found.") - views.forEach { method -> - val routeName = method.annotations[0].annotationClass.simpleName - ?: return@forEach - composable(route = routeName) { method.invoke(null) } - }*/ - - loginGraph(navController = navController) - - composable("home") { Home() } - - composable("recentScrobbles") { RecentScrobbles() } - - composable( - route = "collage?entity={entity}&period={period}", - arguments = listOf( - navArgument("entity") { - type = NavType.StringType - defaultValue = "artist" - }, - navArgument("period") { - type = NavType.StringType - defaultValue = "7day" - } ) - ) { - Collage() } + } + ) { + /* WIP val views = + Reflections("io.musicorum.mobile") + .getMethodsAnnotatedWith(Route::class.java) + Log.d("REGISTRY", "${views.size} routes were found.") + views.forEach { method -> + val routeName = method.annotations[0].annotationClass.simpleName + ?: return@forEach + composable(route = routeName) { method.invoke(null) } + }*/ + + loginGraph(navController = navController) + + composable("home") { Home() } + + composable("recentScrobbles") { RecentScrobbles() } + + composable( + route = "collage?entity={entity}&period={period}", + arguments = listOf( + navArgument("entity") { + type = NavType.StringType + defaultValue = "artist" + }, + navArgument("period") { + type = NavType.StringType + defaultValue = "7day" + } + ) + ) { + Collage() + } - composable( - "charts/detail?index={index}&period={period}", - arguments = listOf( - navArgument("index") { - type = NavType.IntType - defaultValue = 1 - }, - navArgument("period") { - type = NavType.StringType - defaultValue = FetchPeriod.WEEK.value - } - ) - ) { backStack -> - BaseChartDetail(backStack.arguments?.getInt("index")!!) - } + composable( + "charts/detail?index={index}&period={period}", + arguments = listOf( + navArgument("index") { + type = NavType.IntType + defaultValue = 1 + }, + navArgument("period") { + type = NavType.StringType + defaultValue = FetchPeriod.WEEK.value + } + ) + ) { backStack -> + BaseChartDetail(backStack.arguments?.getInt("index")!!) + } - composable("mostListened") { - MostListened() - } + composable("mostListened") { + MostListened() + } - composable( - "user/{usernameArg}", - arguments = listOf(navArgument("usernameArg") { - type = NavType.StringType - }) - ) { - User() - } + composable( + "user/{usernameArg}", + arguments = listOf(navArgument("usernameArg") { + type = NavType.StringType + }) + ) { + User() + } - composable( - "artist/{artistName}", - arguments = listOf(navArgument("artistName") { - type = NavType.StringType - }) - ) { - Artist( - artistName = it.arguments?.getString("artistName")!!, - ) - } + composable( + "artist/{artistName}", + arguments = listOf(navArgument("artistName") { + type = NavType.StringType + }) + ) { + Artist( + artistName = it.arguments?.getString("artistName")!!, + ) + } - composable( - "album/{albumData}", - arguments = listOf(navArgument("albumData") { - type = NavType.StringType - }), - ) { - Album(it.arguments?.getString("albumData"), nav = navController) - } + composable( + "album/{albumData}", + arguments = listOf(navArgument("albumData") { + type = NavType.StringType + }), + ) { + Album(it.arguments?.getString("albumData"), nav = navController) + } - composable("discover") { Discover() } - composable("scrobbling") { Scrobbling() } - composable("charts") { Charts() } - composable("profile") { Account() } - - composable( - "track/{trackData}", - arguments = listOf( - navArgument("trackData") { - type = NavType.StringType - }, - ) - ) { - Track(it.arguments?.getString("trackData")) - } + composable("discover") { Discover() } + composable("scrobbling") { Scrobbling() } + composable("charts") { Charts() } + composable("profile") { Account() } - composable( - "albumTracklist/{albumData}", - arguments = listOf(navArgument("albumData") { + composable( + "track/{trackData}", + arguments = listOf( + navArgument("trackData") { type = NavType.StringType - }) - ) { - val partialAlbum = Json.decodeFromString( - it.arguments!!.getString("albumData")!! - ) - AlbumTracklist(partialAlbum = partialAlbum) - } + }, + ) + ) { + Track(it.arguments?.getString("trackData")) + } - composable("settings") { Settings() } - composable("settings/scrobble") { ScrobbleSettings() } - composable("settings/pendingScrobbles") { PendingScrobbles() } + composable( + "albumTracklist/{albumData}", + arguments = listOf(navArgument("albumData") { + type = NavType.StringType + }) + ) { + val partialAlbum = Json.decodeFromString( + it.arguments!!.getString("albumData")!! + ) + AlbumTracklist(partialAlbum = partialAlbum) + } - composable( - "tag/{tagName}", - arguments = listOf( - navArgument("tagName") { - type = NavType.StringType - }, - ) - ) { - TagScreen() - } + composable("settings") { Settings() } + composable("settings/scrobble") { ScrobbleSettings() } + composable("settings/pendingScrobbles") { PendingScrobbles() } + + composable( + "tag/{tagName}", + arguments = listOf( + navArgument("tagName") { + type = NavType.StringType + }, + ) + ) { + TagScreen() + } + + composable("friends") { + FriendList() } } + } + } } } diff --git a/app/src/main/java/io/musicorum/mobile/components/FriendActivity.kt b/app/src/main/java/io/musicorum/mobile/components/FriendActivity.kt index 2e619f2..77bcd15 100644 --- a/app/src/main/java/io/musicorum/mobile/components/FriendActivity.kt +++ b/app/src/main/java/io/musicorum/mobile/components/FriendActivity.kt @@ -3,11 +3,25 @@ package io.musicorum.mobile.components import android.text.format.DateUtils import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PushPin +import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text @@ -17,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -45,7 +60,9 @@ import kotlinx.serialization.json.Json fun FriendActivity( track: Track, friendImageUrl: String?, - friendUsername: String? + friendUsername: String?, + isPinned: Boolean, + onUnpin: (() -> Unit)? = null, ) { val analytics = LocalAnalytics.current!! val nav = LocalNavigation.current @@ -78,6 +95,19 @@ fun FriendActivity( } } ) + if (isPinned) { + ListItem( + colors = colors, + headlineContent = { Text("Unpin $friendUsername") }, + leadingContent = { + Icon(Icons.Rounded.Close, null) + }, + modifier = Modifier.clickable { + showSheet.value = false + onUnpin?.invoke() + } + ) + } } } @@ -122,6 +152,26 @@ fun FriendActivity( nav?.navigate("user/$friendUsername") } ) + if (isPinned) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(5.dp, (-10).dp) + .size(30.dp) + .clip(CircleShape) + .background(color = LighterGray, shape = CircleShape) + .border(3.dp, KindaBlack, CircleShape) + .padding(7.dp) + ) { + Icon( + imageVector = Icons.Rounded.PushPin, + contentDescription = null, + modifier = Modifier + .rotate((30f)) + .align(Alignment.Center) + ) + } + } } Spacer(Modifier.height(10.dp)) Row { diff --git a/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt b/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt index 0f52900..67b430f 100644 --- a/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt +++ b/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt @@ -2,28 +2,25 @@ package io.musicorum.mobile.components import android.app.SearchManager import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.provider.MediaStore -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Share import androidx.compose.material.icons.rounded.Star import androidx.compose.material3.ExperimentalMaterial3Api @@ -43,11 +40,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap import coil.compose.AsyncImage import io.musicorum.mobile.LocalNavigation import io.musicorum.mobile.coil.defaultImageRequestBuilder @@ -68,7 +63,7 @@ import kotlinx.serialization.json.Json fun TrackSheet( track: Track, show: MutableState, - additionalSheetItems: (@Composable () -> Unit)? = null + additionalSheetItems: (@Composable (ColumnScope.() -> Unit))? = null ) { val coroutine = rememberCoroutineScope() val trackLoved = rememberSaveable { mutableStateOf(track.loved) } @@ -78,17 +73,19 @@ fun TrackSheet( containerColor = LighterGray ) val sheetState = rememberModalBottomSheetState() - val spotifyIntent = ctx.packageManager.getLaunchIntentForPackage("com.spotify.music") - ?.setAction(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH) - ?.putExtra(SearchManager.QUERY, "${track.artist.name} ${track.name}") + val playIntent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH).apply { + putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/audio") + putExtra(MediaStore.EXTRA_MEDIA_TITLE, track.name) + putExtra(MediaStore.EXTRA_MEDIA_ARTIST, track.artist.name) + putExtra(SearchManager.QUERY, track.name) + } ModalBottomSheet( onDismissRequest = { show.value = false }, containerColor = LighterGray, sheetState = sheetState, - - ) { - + modifier = Modifier.safeDrawingPadding() + ) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -184,30 +181,16 @@ fun TrackSheet( leadingContent = { Icon(Icons.Rounded.Album, null) } ) - val spotify = try { - ctx.packageManager.getApplicationIcon("com.spotify.music") - .toBitmap() - .asImageBitmap() - } catch (_: PackageManager.NameNotFoundException) { - null - } - - spotify?.let { ListItem( modifier = Modifier .fillMaxWidth() - .clickable { ctx.startActivity(spotifyIntent) }, - headlineContent = { Text(text = "Play on Spotify") }, + .clickable { ctx.startActivity(playIntent) }, + headlineContent = { Text(text = "Play") }, leadingContent = { - Image( - bitmap = it, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) + Icon(imageVector = Icons.Rounded.PlayArrow, contentDescription = null) }, colors = listColors ) - } ListItem( modifier = Modifier @@ -228,6 +211,6 @@ fun TrackSheet( leadingContent = { Icon(Icons.Rounded.Share, null) }, colors = listColors ) - Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + //Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } } \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/datastore/UserData.kt b/app/src/main/java/io/musicorum/mobile/datastore/UserData.kt index 1c14cbe..47e709d 100644 --- a/app/src/main/java/io/musicorum/mobile/datastore/UserData.kt +++ b/app/src/main/java/io/musicorum/mobile/datastore/UserData.kt @@ -1,8 +1,10 @@ package io.musicorum.mobile.datastore import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey object UserData { const val DataStoreName = "userdata" val SESSION_KEY = stringPreferencesKey("session_key") + val PINNED_USERS = stringSetPreferencesKey("pinned_users") } \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt b/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt index 700b19f..6f9fa43 100644 --- a/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt +++ b/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt @@ -1,7 +1,5 @@ package io.musicorum.mobile.router -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.BarChart @@ -14,20 +12,19 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState +import io.musicorum.mobile.LocalNavigation import io.musicorum.mobile.ui.theme.LighterGray import io.musicorum.mobile.ui.theme.MostlyRed import java.util.Locale @Composable -fun BottomNavBar(nav: NavHostController) { +fun BottomNavBar() { val items = listOf("Home", "Discover", "Scrobbling", "Charts", "Profile") + val nav = LocalNavigation.current!! val icons = listOf( Icons.Rounded.Home, Icons.Rounded.Search, @@ -41,30 +38,28 @@ fun BottomNavBar(nav: NavHostController) { selectedTextColor = Color.White ) - val navBackStackEntry by nav.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination + val navBackStackEntry = nav.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry.value?.destination - Box(modifier = Modifier.background(LighterGray)) { - NavigationBar(containerColor = Color.Transparent) { - items.forEachIndexed { index, s -> - NavigationBarItem( - selected = currentDestination?.hierarchy?.any { it.route?.lowercase() == s.lowercase() } == true, - label = { Text(text = s, maxLines = 1) }, - onClick = { - nav.navigate(s.lowercase(Locale.ROOT)) - { - launchSingleTop = true - restoreState = true - popUpTo(nav.graph.findStartDestination().id) { - saveState = true - } + NavigationBar(containerColor = LighterGray) { + items.forEachIndexed { index, s -> + NavigationBarItem( + selected = currentDestination?.hierarchy?.any { it.route?.lowercase() == s.lowercase() } == true, + label = { Text(text = s, maxLines = 1) }, + onClick = { + nav.navigate(s.lowercase(Locale.ROOT)) + { + launchSingleTop = true + restoreState = true + popUpTo(nav.graph.findStartDestination().id) { + saveState = true } - }, - icon = { Icon(icons[index], contentDescription = "nav icon") }, - alwaysShowLabel = false, - colors = navItemColors - ) - } + } + }, + icon = { Icon(icons[index], contentDescription = "nav icon") }, + alwaysShowLabel = false, + colors = navItemColors + ) } } } diff --git a/app/src/main/java/io/musicorum/mobile/router/Controller.kt b/app/src/main/java/io/musicorum/mobile/router/Controller.kt index e5f6114..144ec7b 100644 --- a/app/src/main/java/io/musicorum/mobile/router/Controller.kt +++ b/app/src/main/java/io/musicorum/mobile/router/Controller.kt @@ -16,7 +16,7 @@ import io.musicorum.mobile.views.charts.Charts import io.musicorum.mobile.views.home.Home import io.musicorum.mobile.views.individual.Album import io.musicorum.mobile.views.individual.Track -import io.musicorum.mobile.views.individual.User +import io.musicorum.mobile.views.individual.user.User import io.musicorum.mobile.views.login.loginGraph import io.musicorum.mobile.views.scrobbling.Scrobbling diff --git a/app/src/main/java/io/musicorum/mobile/router/Routes.kt b/app/src/main/java/io/musicorum/mobile/router/Routes.kt index f604944..43b1eb1 100644 --- a/app/src/main/java/io/musicorum/mobile/router/Routes.kt +++ b/app/src/main/java/io/musicorum/mobile/router/Routes.kt @@ -41,4 +41,5 @@ object Routes { val entityString = entity?.entityName ?: "ARTIST" return "collage?period=$periodString&entity=$entityString" } + const val friends = "friends" } \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/viewmodels/UserViewModel.kt b/app/src/main/java/io/musicorum/mobile/viewmodels/UserViewModel.kt deleted file mode 100644 index ef51b2f..0000000 --- a/app/src/main/java/io/musicorum/mobile/viewmodels/UserViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package io.musicorum.mobile.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import io.musicorum.mobile.ktor.endpoints.UserEndpoint -import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumArtistEndpoint -import io.musicorum.mobile.models.FetchPeriod -import io.musicorum.mobile.repositories.LocalUserRepository -import io.musicorum.mobile.serialization.RecentTracks -import io.musicorum.mobile.serialization.TopAlbumsResponse -import io.musicorum.mobile.serialization.TopArtistsResponse -import io.musicorum.mobile.serialization.User -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -class UserViewModel @Inject constructor( - application: Application, - savedStateHandle: SavedStateHandle -) : AndroidViewModel(application) { - val user by lazy { MutableLiveData() } - val topArtists by lazy { MutableLiveData() } - val recentTracks by lazy { MutableLiveData() } - val topAlbums by lazy { MutableLiveData() } - val isRefreshing by lazy { MutableStateFlow(false) } - val errored by lazy { MutableLiveData(false) } - val ctx = application - private val usernameArg = savedStateHandle.get("usernameArg") - private val localUser = LocalUserRepository(ctx) - - fun refresh() { - isRefreshing.value = true - recentTracks.value = null - getRecentTracks(limit = 4, null) - } - - fun getUser() = kotlin.runCatching { - viewModelScope.launch { - if (usernameArg == null) { - val localUser = LocalUserRepository(ctx).fetch() - user.value = localUser - return@launch - } - val userInfo = UserEndpoint.getUser(usernameArg) - user.value = userInfo - if (userInfo == null) errored.value = true - } - } - - fun getTopArtists(limit: Int?, period: FetchPeriod?) = kotlin.runCatching { - viewModelScope.launch { - val username = usernameArg ?: localUser.getUser().username - val res = UserEndpoint.getTopArtists(username, limit, period) - if (res != null) { - val musRes = - MusicorumArtistEndpoint.fetchArtist(res.topArtists.artists) - musRes.forEachIndexed { index, trackResponse -> - val trackImageUrl = trackResponse.resources.getOrNull(0)?.bestImageUrl ?: "" - res.topArtists.artists[index].bestImageUrl = trackImageUrl - } - topArtists.value = res - } - } - } - - fun getRecentTracks(limit: Int?, extended: Boolean?) = kotlin.runCatching { - viewModelScope.launch { - val username = usernameArg ?: localUser.getUser().username - val res = UserEndpoint.getRecentTracks(username, null, limit, extended) - recentTracks.value = res - isRefreshing.value = false - } - } - - fun getTopAlbums(period: FetchPeriod?, limit: Int?) = kotlin.runCatching { - viewModelScope.launch { - val username = usernameArg ?: localUser.getUser().username - val res = UserEndpoint.getTopAlbums(username, period, limit) - topAlbums.value = res - } - } - - init { - getUser() - getTopArtists(null, FetchPeriod.MONTH) - getRecentTracks(limit = 4, null) - getTopAlbums(FetchPeriod.MONTH, null) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/Account.kt b/app/src/main/java/io/musicorum/mobile/views/Account.kt index bac08a3..3346070 100644 --- a/app/src/main/java/io/musicorum/mobile/views/Account.kt +++ b/app/src/main/java/io/musicorum/mobile/views/Account.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.lifecycle.viewmodel.compose.viewModel import io.musicorum.mobile.components.CenteredLoadingSpinner import io.musicorum.mobile.viewmodels.AccountVm -import io.musicorum.mobile.views.individual.User +import io.musicorum.mobile.views.individual.user.User @Composable fun Account(model: AccountVm = viewModel()) { diff --git a/app/src/main/java/io/musicorum/mobile/views/Discover.kt b/app/src/main/java/io/musicorum/mobile/views/Discover.kt index b7eae71..4eca7a9 100644 --- a/app/src/main/java/io/musicorum/mobile/views/Discover.kt +++ b/app/src/main/java/io/musicorum/mobile/views/Discover.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -44,6 +45,7 @@ import io.musicorum.mobile.components.AlbumListItem import io.musicorum.mobile.components.ArtistListItem import io.musicorum.mobile.components.CenteredLoadingSpinner import io.musicorum.mobile.components.TrackListItem +import io.musicorum.mobile.router.BottomNavBar import io.musicorum.mobile.router.Routes import io.musicorum.mobile.ui.theme.ContentSecondary import io.musicorum.mobile.ui.theme.KindaBlack @@ -67,102 +69,105 @@ fun Discover(viewModel: DiscoverVm = viewModel()) { val nav = LocalNavigation.current - Column( - modifier = Modifier - .padding(vertical = 20.dp) - .fillMaxSize() - .background(KindaBlack) - .verticalScroll(rememberScrollState()) - ) { - Text( - stringResource(R.string.discover), - style = Typography.displaySmall, - modifier = Modifier.padding(start = 20.dp) - ) - - DockedSearchBar( - query = query, - onQueryChange = { viewModel.updateQuery(it) }, - onSearch = { - viewModel.search() - presentResults.value = true - }, - active = false, - onActiveChange = {}, - colors = searchBarColors, + Scaffold(bottomBar = { BottomNavBar() }) { pv -> + Column( modifier = Modifier - .padding(top = 10.dp) - .align(CenterHorizontally), - placeholder = { Text(stringResource(R.string.search_on_last_fm)) }, - leadingIcon = { Icon(Icons.Rounded.Search, null) } - ) {} - - if (!presentResults.value) return + .padding(vertical = 20.dp) + .padding(pv) + .fillMaxSize() + .background(KindaBlack) + .verticalScroll(rememberScrollState()) + ) { + Text( + stringResource(R.string.discover), + style = Typography.displaySmall, + modifier = Modifier.padding(start = 20.dp) + ) - if (busy) { - CenteredLoadingSpinner() - return - } + DockedSearchBar( + query = query, + onQueryChange = { viewModel.updateQuery(it) }, + onSearch = { + viewModel.search() + presentResults.value = true + }, + active = false, + onActiveChange = {}, + colors = searchBarColors, + modifier = Modifier + .padding(top = 10.dp) + .align(CenterHorizontally), + placeholder = { Text(stringResource(R.string.search_on_last_fm)) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) } + ) {} - Header( - title = stringResource(R.string.tracks), - results = tracks.size, - icon = Icons.Rounded.Audiotrack - ) + if (!presentResults.value) return@Column - if (tracks.isNotEmpty()) { - tracks.take(4).forEach { - TrackListItem(track = it) + if (busy) { + CenteredLoadingSpinner() + return@Column } - } - Header( - title = stringResource(id = R.string.albums), - results = albums.size, - icon = Icons.Outlined.Album - ) - if (albums.isNotEmpty()) { - albums.take(4).forEach { - AlbumListItem(it) + Header( + title = stringResource(R.string.tracks), + results = tracks.size, + icon = Icons.Rounded.Audiotrack + ) + + if (tracks.isNotEmpty()) { + tracks.take(4).forEach { + TrackListItem(track = it) + } } - } - Header( - title = stringResource(id = R.string.artists), - results = artists.size, - icon = Icons.Rounded.Star - ) - if (artists.isNotEmpty()) { - artists.take(4).forEach { - ArtistListItem(artist = it) + Header( + title = stringResource(id = R.string.albums), + results = albums.size, + icon = Icons.Outlined.Album + ) + if (albums.isNotEmpty()) { + albums.take(4).forEach { + AlbumListItem(it) + } } - } - Header( - title = "Users", - results = users.size, - icon = Icons.Rounded.Person - ) - if (users.isNotEmpty()) { - val model = defaultImageRequestBuilder( - url = users.first().user.bestImageUrl, - PlaceholderType.USER + Header( + title = stringResource(id = R.string.artists), + results = artists.size, + icon = Icons.Rounded.Star ) - ListItem( - headlineContent = { Text(users.first().user.name) }, - leadingContent = { - AsyncImage( - model = model, - contentDescription = null, - modifier = Modifier - .size(50.dp) - .clip(CircleShape) - ) - }, - modifier = Modifier.clickable { - nav?.navigate(Routes.user(users.first().user.name)) + if (artists.isNotEmpty()) { + artists.take(4).forEach { + ArtistListItem(artist = it) } + } + + Header( + title = "Users", + results = users.size, + icon = Icons.Rounded.Person ) + if (users.isNotEmpty()) { + val model = defaultImageRequestBuilder( + url = users.first().user.bestImageUrl, + PlaceholderType.USER + ) + ListItem( + headlineContent = { Text(users.first().user.name) }, + leadingContent = { + AsyncImage( + model = model, + contentDescription = null, + modifier = Modifier + .size(50.dp) + .clip(CircleShape) + ) + }, + modifier = Modifier.clickable { + nav?.navigate(Routes.user(users.first().user.name)) + } + ) + } } } } diff --git a/app/src/main/java/io/musicorum/mobile/views/charts/Charts.kt b/app/src/main/java/io/musicorum/mobile/views/charts/Charts.kt index dad4e0f..bf7185f 100644 --- a/app/src/main/java/io/musicorum/mobile/views/charts/Charts.kt +++ b/app/src/main/java/io/musicorum/mobile/views/charts/Charts.kt @@ -72,6 +72,7 @@ import io.musicorum.mobile.R import io.musicorum.mobile.coil.defaultImageRequestBuilder import io.musicorum.mobile.components.CenteredLoadingSpinner import io.musicorum.mobile.models.ResourceEntity +import io.musicorum.mobile.router.BottomNavBar import io.musicorum.mobile.router.Routes import io.musicorum.mobile.serialization.NavigationTrack import io.musicorum.mobile.serialization.entities.Album @@ -106,7 +107,9 @@ fun Charts() { val userGradient = getDarkenGradient(userColor) - Scaffold(floatingActionButton = { CollageFab() }) { paddingValues -> + Scaffold( + floatingActionButton = { CollageFab() }, + bottomBar = { BottomNavBar() }) { paddingValues -> Column(modifier = Modifier.padding(paddingValues)) { Column( modifier = Modifier diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityState.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityState.kt new file mode 100644 index 0000000..e66c8eb --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityState.kt @@ -0,0 +1,9 @@ +package io.musicorum.mobile.views.friendlist + +import io.musicorum.mobile.serialization.entities.Track + +data class FriendActivityState( + val track: Track? = null, + val loading: Boolean = false, + val nowPlaying: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityViewModel.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityViewModel.kt new file mode 100644 index 0000000..524be59 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityViewModel.kt @@ -0,0 +1,57 @@ +package io.musicorum.mobile.views.friendlist + +import android.app.Application +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.musicorum.mobile.ktor.endpoints.UserEndpoint +import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumTrackEndpoint +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class FriendActivityViewModel(application: Application) : AndroidViewModel(application) { + val state = MutableStateFlow(FriendActivityState()) + val app = application + + fun fetchActivity(username: String) { + if (state.value.track != null) return + viewModelScope.launch { + state.update { + it.copy(loading = true) + } + val res = UserEndpoint.getRecentTracks(username, null, 1) + res?.let { + val track = res.recentTracks.tracks.first() + state.update { + it.copy( + track = track, + loading = false, + nowPlaying = track.attributes?.nowPlaying?.toBooleanStrictOrNull() ?: false + ) + } + val musRes = MusicorumTrackEndpoint.fetchTracks(res.recentTracks.tracks) + res.recentTracks.tracks.onEach { + musRes[0]?.let { r -> + it.bestImageUrl = r.bestResource?.bestImageUrl ?: "" + } + } + + state.update { + it.copy( + track = track, + loading = false, + nowPlaying = track.attributes?.nowPlaying?.toBooleanStrictOrNull() ?: false + ) + } + } + } + } + + fun launchWebProfile(username: String) { + val uri = Uri.parse("https://last.fm/user/$username") + val intent = Intent(Intent.ACTION_VIEW, uri) + app.startActivity(intent) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendList.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendList.kt new file mode 100644 index 0000000..4a1cc67 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendList.kt @@ -0,0 +1,260 @@ +package io.musicorum.mobile.views.friendlist + +import android.text.format.DateUtils +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +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.MediumTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.google.accompanist.placeholder.PlaceholderHighlight +import com.google.accompanist.placeholder.material.shimmer +import com.google.accompanist.placeholder.placeholder +import io.musicorum.mobile.LocalNavigation +import io.musicorum.mobile.R +import io.musicorum.mobile.coil.PlaceholderType +import io.musicorum.mobile.coil.defaultImageRequestBuilder +import io.musicorum.mobile.components.CenteredLoadingSpinner +import io.musicorum.mobile.components.TrackSheet +import io.musicorum.mobile.router.Routes +import io.musicorum.mobile.serialization.NavigationTrack +import io.musicorum.mobile.serialization.UserData +import io.musicorum.mobile.ui.theme.ContentSecondary +import io.musicorum.mobile.ui.theme.KindaBlack +import io.musicorum.mobile.ui.theme.Typography +import io.musicorum.mobile.utils.Rive + +@Composable +fun FriendList(vm: FriendListViewModel = viewModel()) { + val state by vm.state.collectAsState() + + Scaffold( + topBar = { AppBar() }, + snackbarHost = { SnackbarHost(vm.snackbarHostState) } + ) { pv -> + Column(Modifier.padding(pv)) { + if (state.loading) { + CenteredLoadingSpinner() + } else { + Text( + "${state.friends.size} friends", + modifier = Modifier.padding(start = 15.dp, bottom = 15.dp), + color = ContentSecondary + ) + LazyVerticalGrid( + columns = GridCells.FixedSize(160.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.friends) { user -> + val activityViewModel: FriendActivityViewModel = viewModel(key = user.name) + val isPinned = user.name in state.pinnedUsers + FriendActivity(user = user, isPinned = isPinned, vm = activityViewModel) { + if (isPinned) vm.unpinUser(user) + else vm.pinUser(user) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppBar() { + val nav = LocalNavigation.current + MediumTopAppBar(title = { Text("Friends") }, navigationIcon = { + IconButton(onClick = { nav?.popBackStack() }) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null) + } + }) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FriendActivity( + vm: FriendActivityViewModel, + user: UserData, + isPinned: Boolean, + onPin: () -> Unit +) { + val state by vm.state.collectAsState() + val reqModel = defaultImageRequestBuilder(url = user.bestImageUrl) + val showBottomSheet = remember { mutableStateOf(false) } + val showTrackBottomSheet = remember { mutableStateOf(false) } + val haptic = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + val trackRowISource = remember { MutableInteractionSource() } + val nav = LocalNavigation.current + + + LaunchedEffect(key1 = Unit) { + vm.fetchActivity(user.name) + } + + if (showBottomSheet.value) { + UserBottomSheet( + userData = user, + isPinned = isPinned, + onDismiss = { showBottomSheet.value = false } + ) { + showBottomSheet.value = false + onPin() + } + } + + if (showTrackBottomSheet.value) { + state.track?.let { + TrackSheet(track = it, showTrackBottomSheet) {} + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .combinedClickable( + indication = null, + interactionSource = interactionSource, + onClick = { + nav?.navigate(Routes.user(user.name)) + }, + onLongClick = { + showBottomSheet.value = true + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + ) + .consumeWindowInsets(WindowInsets.navigationBars) + ) { + AsyncImage( + model = reqModel, + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(105.dp) + .indication(interactionSource, LocalIndication.current) + ) + Text( + text = user.name, + style = Typography.titleMedium, + modifier = Modifier.padding(vertical = 5.dp) + ) + Spacer(modifier = Modifier.height(3.dp)) + Row( + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .placeholder( + visible = state.loading, + color = KindaBlack, + shape = RoundedCornerShape(3.dp), + highlight = PlaceholderHighlight.shimmer() + ) + .clip(RoundedCornerShape(3.dp)) + .combinedClickable( + indication = LocalIndication.current, + interactionSource = trackRowISource, + onClick = { + state.track?.let { + nav?.navigate(Routes.track(NavigationTrack(it.name, it.artist.name))) + } + }, + onLongClick = { showTrackBottomSheet.value = true }, + ) + ) { + val albumModel = + defaultImageRequestBuilder(url = state.track?.bestImageUrl, PlaceholderType.ALBUM) + AsyncImage( + model = albumModel, contentDescription = null, modifier = Modifier + .clip(RoundedCornerShape(3.dp)) + .size(40.dp) + .indication(trackRowISource, LocalIndication.current), + colorFilter = if (!state.nowPlaying) { + ColorFilter.colorMatrix(ColorMatrix().apply { this.setToSaturation(0F) }) + } else null + ) + Column( + modifier = Modifier + .padding(start = 5.dp) + .fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (state.nowPlaying) { + Rive.AnimationFor(id = R.raw.nowplaying, modifier = Modifier.size(10.dp)) + } + Text( + text = state.track?.name ?: "Unknown", + style = Typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (state.nowPlaying) { + Text( + text = state.track?.artist?.name ?: "Unknown", + style = Typography.labelSmall, + color = ContentSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } else { + val now = System.currentTimeMillis() + state.track?.let { + val string = DateUtils.getRelativeTimeSpanString( + it.date!!.uts.toLong() * 1000, + now, + DateUtils.SECOND_IN_MILLIS + ).toString() + Text( + text = string, + style = Typography.labelSmall, + color = ContentSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListState.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListState.kt new file mode 100644 index 0000000..28f6109 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListState.kt @@ -0,0 +1,9 @@ +package io.musicorum.mobile.views.friendlist + +import io.musicorum.mobile.serialization.UserData + +data class FriendListState( + val friends: List = emptyList(), + val loading: Boolean = false, + val pinnedUsers: Set = emptySet() +) \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListViewModel.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListViewModel.kt new file mode 100644 index 0000000..8f4e8b3 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListViewModel.kt @@ -0,0 +1,87 @@ +package io.musicorum.mobile.views.friendlist + +import android.app.Application +import androidx.compose.material3.SnackbarHostState +import androidx.datastore.preferences.core.edit +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.musicorum.mobile.datastore.UserData +import io.musicorum.mobile.ktor.endpoints.UserEndpoint +import io.musicorum.mobile.repositories.LocalUserRepository +import io.musicorum.mobile.userData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import io.musicorum.mobile.serialization.UserData as LastfmUserData + +class FriendListViewModel(application: Application) : AndroidViewModel(application) { + val state = MutableStateFlow(FriendListState()) + private val ctx = application + val snackbarHostState = SnackbarHostState() + + private fun fetchFriends() { + viewModelScope.launch { + state.update { + it.copy(loading = true) + } + val localUser = LocalUserRepository(getApplication()).getUser() + val res = UserEndpoint.getFriends(localUser.username, null) + res?.let { + state.update { + it.copy(friends = res.friends.users, loading = false) + } + } + } + } + + fun pinUser(userData: LastfmUserData) { + viewModelScope.launch { + val currentSet = ctx.userData.data.map { + it[UserData.PINNED_USERS] ?: emptySet() + }.first().toMutableSet() + + currentSet.add(userData.name) + + ctx.userData.edit { + it[UserData.PINNED_USERS] = currentSet.toSet() + } + state.update { + it.copy(pinnedUsers = currentSet.toSet()) + } + snackbarHostState.showSnackbar("${userData.name} pinned on home screen") + } + } + + fun unpinUser(userData: LastfmUserData) { + viewModelScope.launch { + val currentSet = ctx.userData.data.map { + it[UserData.PINNED_USERS] ?: emptySet() + }.first().toMutableSet() + + currentSet.remove(userData.name) + + ctx.userData.edit { + it[UserData.PINNED_USERS] = currentSet.toSet() + } + + state.update { + it.copy(pinnedUsers = currentSet.toSet()) + } + snackbarHostState.showSnackbar("${userData.name} removed from pinned users") + } + } + + init { + fetchFriends() + viewModelScope.launch { + val pinnedUsers = ctx.userData.data.map { + it[UserData.PINNED_USERS] ?: emptySet() + }.first() + state.update { + it.copy(pinnedUsers = pinnedUsers) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/UserBottomSheet.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/UserBottomSheet.kt new file mode 100644 index 0000000..7d4650f --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/UserBottomSheet.kt @@ -0,0 +1,129 @@ +package io.musicorum.mobile.views.friendlist + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PushPin +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.musicorum.mobile.LocalNavigation +import io.musicorum.mobile.coil.PlaceholderType +import io.musicorum.mobile.coil.defaultImageRequestBuilder +import io.musicorum.mobile.router.Routes +import io.musicorum.mobile.serialization.UserData +import io.musicorum.mobile.ui.theme.LighterGray +import io.musicorum.mobile.ui.theme.Typography + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun UserBottomSheet( + userData: UserData, + isPinned: Boolean, + onDismiss: () -> Unit, + onPin: () -> Unit, +) { + val nav = LocalNavigation.current + val userUri = Uri.parse("https://last.fm/user/${userData.name}") + val userIntent = Intent(Intent.ACTION_VIEW, userUri) + val ctx = LocalContext.current + + val userModel = defaultImageRequestBuilder( + url = userData.bestImageUrl, + placeholderType = PlaceholderType.USER + ) + val listItemColors = ListItemDefaults.colors( + containerColor = LighterGray, + ) + ModalBottomSheet( + modifier = Modifier.consumeWindowInsets(WindowInsets.navigationBars), + onDismissRequest = { onDismiss() }, + containerColor = LighterGray, + ) { + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 20.dp) + ) { + AsyncImage( + model = userModel, contentDescription = null, modifier = Modifier + .clip(CircleShape) + .size(80.dp) + ) + Column(modifier = Modifier.padding(start = 10.dp)) { + Text(userData.name, style = Typography.headlineMedium) + userData.realName?.let { + Text(it, style = Typography.titleMedium) + } + } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + + if (isPinned) { + ListItem( + colors = listItemColors, + headlineContent = { Text("Unpin") }, + leadingContent = { Icon(Icons.Rounded.Close, contentDescription = null) }, + modifier = Modifier.clickable { onPin() } + ) + } else { + ListItem( + colors = listItemColors, + headlineContent = { Text("Pin on home screen") }, + leadingContent = { Icon(Icons.Rounded.PushPin, contentDescription = null) }, + modifier = Modifier.clickable { onPin() } + ) + } + + ListItem( + colors = listItemColors, + headlineContent = { Text("View ${userData.name}'s profile") }, + leadingContent = { + AsyncImage( + model = userModel, + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(25.dp) + ) + }, + modifier = Modifier.clickable { + nav?.navigate(Routes.user(userData.name)) + } + ) + + ListItem( + colors = listItemColors, + headlineContent = { Text("Open on Last.fm") }, + leadingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = null + ) + }, + modifier = Modifier.clickable { ctx.startActivity(userIntent) } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/home/Home.kt b/app/src/main/java/io/musicorum/mobile/views/home/Home.kt index 98a3bde..498a3a5 100644 --- a/app/src/main/java/io/musicorum/mobile/views/home/Home.kt +++ b/app/src/main/java/io/musicorum/mobile/views/home/Home.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -29,6 +30,8 @@ import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -62,6 +65,7 @@ import io.musicorum.mobile.components.LabelType import io.musicorum.mobile.components.RewindCard import io.musicorum.mobile.components.skeletons.GenericCardPlaceholder import io.musicorum.mobile.models.PartialUser +import io.musicorum.mobile.router.BottomNavBar import io.musicorum.mobile.router.Route import io.musicorum.mobile.router.Routes import io.musicorum.mobile.ui.theme.ContentSecondary @@ -80,199 +84,222 @@ fun Home(vm: HomeViewModel = hiltViewModel()) { val state by vm.state.collectAsState() val nav = LocalNavigation.current - SwipeRefresh( - state = rememberSwipeRefreshState(state.isRefreshing), - onRefresh = { vm.refresh() }) { - Column( - Modifier - .verticalScroll(rememberScrollState()) - .background(KindaBlack) - .fillMaxSize() - .padding(top = 20.dp, bottom = 20.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = CenterVertically + Scaffold( + snackbarHost = { SnackbarHost(vm.snackbarHostState) }, + bottomBar = { BottomNavBar() }) { pv -> + SwipeRefresh( + state = rememberSwipeRefreshState(state.isRefreshing), + modifier = Modifier.padding(pv), + onRefresh = { vm.refresh() }) { + Column( + Modifier + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .background(KindaBlack) + .fillMaxSize() + .padding(top = 20.dp, bottom = 20.dp) ) { - Text( - text = "Home", - style = Typography.displaySmall, - modifier = Modifier.padding(start = 20.dp) - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = CenterVertically + ) { + Text( + text = "Home", + style = Typography.displaySmall, + modifier = Modifier.padding(start = 20.dp) + ) - BadgedBox( - modifier = Modifier - .clip(CircleShape) - .clickable { nav?.navigate(Routes.settings) } - .padding(12.dp), - badge = { - if (state.showSettingsBade) Badge(containerColor = MostlyRed) + BadgedBox( + modifier = Modifier + .clip(CircleShape) + .clickable { nav?.navigate(Routes.settings) } + .padding(12.dp), + badge = { + if (state.showSettingsBade) Badge(containerColor = MostlyRed) + } + ) { + //IconButton(onClick = { nav.navigate("settings") }) { + Icon(Icons.Rounded.Settings, contentDescription = null) + //} } - ) { - //IconButton(onClick = { nav.navigate("settings") }) { - Icon(Icons.Rounded.Settings, contentDescription = null) - //} } - } - if (state.showRewindCard) { - RewindCard(description = state.rewindCardMessage) { - vm.launchRewind() + if (state.showRewindCard) { + RewindCard(description = state.rewindCardMessage) { + vm.launchRewind() + } } - } - if (state.user != null && state.userPalette != null) { - UserCard(state.user!!, state.userPalette!!, state.weeklyScrobbles) - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .height(150.dp) - .padding(20.dp, 20.dp, 20.dp) - .clip(RoundedCornerShape(15.dp)) - .placeholder( - true, - color = LighterGray, - highlight = PlaceholderHighlight.shimmer() - ) - ) - } + if (state.user != null && state.userPalette != null) { + UserCard(state.user!!, state.userPalette!!, state.weeklyScrobbles) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .padding(20.dp, 20.dp, 20.dp) + .clip(RoundedCornerShape(15.dp)) + .placeholder( + true, + color = LighterGray, + highlight = PlaceholderHighlight.shimmer() + ) + ) + } - Spacer(Modifier.height(20.dp)) + Spacer(Modifier.height(20.dp)) - if (state.isOffline) { - Box( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth() - .border(1.dp, EvenLighterGray, RoundedCornerShape(12.dp)) - .padding(16.dp) - ) { - Row { - Icon( - Icons.Rounded.WifiOff, - contentDescription = null, - modifier = Modifier - .align(CenterVertically) - .size(30.dp) - ) - Column(modifier = Modifier.padding(start = 22.dp)) { - Text( - stringResource(id = R.string.youre_offline), - style = Typography.titleMedium + if (state.isOffline) { + Box( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .border(1.dp, EvenLighterGray, RoundedCornerShape(12.dp)) + .padding(16.dp) + ) { + Row { + Icon( + Icons.Rounded.WifiOff, + contentDescription = null, + modifier = Modifier + .align(CenterVertically) + .size(30.dp) + ) + Column(modifier = Modifier.padding(start = 22.dp)) { + Text( + stringResource(id = R.string.youre_offline), + style = Typography.titleMedium + ) + Text( + stringResource(R.string.outdated_data_notice), + style = Typography.bodySmall + ) + } + } + } + if (state.hasPendingScrobbles) { + Row(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { + Icon( + Icons.Rounded.Error, + null, + tint = ContentSecondary, + modifier = Modifier.size(20.dp) ) Text( - stringResource(R.string.outdated_data_notice), - style = Typography.bodySmall + stringResource(R.string.pending_scrobbles_notice), + style = Typography.labelSmall, + color = ContentSecondary, + modifier = Modifier + .align(CenterVertically) + .padding(start = 10.dp) ) } + Spacer(Modifier.height(20.dp)) } } - if (state.hasPendingScrobbles) { - Row(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { - Icon( - Icons.Rounded.Error, - null, - tint = ContentSecondary, - modifier = Modifier.size(20.dp) - ) - Text( - stringResource(R.string.pending_scrobbles_notice), - style = Typography.labelSmall, - color = ContentSecondary, - modifier = Modifier - .align(CenterVertically) - .padding(start = 10.dp) - ) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .clickable { nav?.navigate("recentScrobbles") }, + verticalAlignment = CenterVertically + ) { + Text( + text = stringResource(R.string.recent_scrobbles), + style = Typography.headlineSmall, + modifier = Modifier.padding(start = 20.dp) + ) + IconButton(onClick = { nav?.navigate("recentScrobbles") }) { + Icon(Icons.Rounded.ChevronRight, contentDescription = null) } - Spacer(Modifier.height(20.dp)) } - } - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .height(50.dp) - .clickable { nav?.navigate("recentScrobbles") }, - verticalAlignment = CenterVertically - ) { - Text( - text = stringResource(R.string.recent_scrobbles), - style = Typography.headlineSmall, - modifier = Modifier.padding(start = 20.dp) + Spacer(modifier = Modifier.height(10.dp)) + HorizontalTracksRow( + tracks = state.recentTracks, + labelType = LabelType.DATE ) - IconButton(onClick = { nav?.navigate("recentScrobbles") }) { - Icon(Icons.Rounded.ChevronRight, contentDescription = null) - } - } - Spacer(modifier = Modifier.height(10.dp)) - HorizontalTracksRow( - tracks = state.recentTracks, - labelType = LabelType.DATE - ) - Spacer(Modifier.height(20.dp)) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .height(50.dp) - .clickable { nav?.navigate("mostListened") }, - verticalAlignment = CenterVertically - ) { - Text( - text = stringResource(R.string.most_listened_week), - style = Typography.headlineSmall, - modifier = Modifier.padding(start = 20.dp) - ) - IconButton(onClick = { nav?.navigate("mostListened") }) { - Icon(Icons.Rounded.ChevronRight, contentDescription = null) + Spacer(Modifier.height(20.dp)) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .clickable { nav?.navigate("mostListened") }, + verticalAlignment = CenterVertically + ) { + Text( + text = stringResource(R.string.most_listened_week), + style = Typography.headlineSmall, + modifier = Modifier.padding(start = 20.dp) + ) + IconButton(onClick = { nav?.navigate(Routes.mostListened) }) { + Icon(Icons.Rounded.ChevronRight, contentDescription = null) + } } - } - Spacer(modifier = Modifier.height(10.dp)) - HorizontalTracksRow( - tracks = state.weekTracks, - labelType = LabelType.ARTIST_NAME - ) + Spacer(modifier = Modifier.height(10.dp)) + HorizontalTracksRow( + tracks = state.weekTracks, + labelType = LabelType.ARTIST_NAME + ) - Spacer(Modifier.height(20.dp)) - Text( - text = stringResource(R.string.friends_activity), - style = Typography.headlineSmall, - modifier = Modifier.padding(start = 20.dp) - ) - Spacer(modifier = Modifier.height(10.dp)) - Row( - modifier = Modifier - .padding(start = 20.dp) - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (state.isOffline) { - Text(text = stringResource(R.string.youre_offline)) - } else { - if (state.friendsActivity == null && state.friends == null) { - if (state.hasError == true) { - Text( - text = stringResource(R.string.empty_friendlist_message), - softWrap = true, - style = Subtitle1 - ) - } else { - GenericCardPlaceholder(visible = true) - GenericCardPlaceholder(visible = true) - GenericCardPlaceholder(visible = true) - } + Spacer(Modifier.height(20.dp)) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .clickable { nav?.navigate(Routes.friends) }, + verticalAlignment = CenterVertically + ) { + Text( + text = stringResource(R.string.friends_activity), + style = Typography.headlineSmall, + modifier = Modifier.padding(start = 20.dp) + ) + IconButton(onClick = { nav?.navigate(Routes.friends) }) { + Icon(Icons.Rounded.ChevronRight, contentDescription = null) + } + } + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier + .padding(start = 20.dp) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (state.isOffline) { + Text(text = stringResource(R.string.youre_offline)) } else { - state.friendsActivity?.forEachIndexed { i, rt -> - FriendActivity( - track = rt.recentTracks.tracks[0], - friendImageUrl = state.friends?.get(i)?.bestImageUrl, - friendUsername = state.friends?.get(i)?.name - ) + if (state.friendsActivity == null && state.friends == null) { + if (state.hasError) { + Text( + text = stringResource(R.string.empty_friendlist_message), + softWrap = true, + style = Subtitle1 + ) + } else { + GenericCardPlaceholder(visible = true) + GenericCardPlaceholder(visible = true) + GenericCardPlaceholder(visible = true) + } + } else { + state.friendsActivity?.forEachIndexed { i, rt -> + FriendActivity( + isPinned = state.friends?.get(i)?.name in state.pinnedUsers, + track = rt.recentTracks.tracks[0], + friendImageUrl = state.friends?.get(i)?.bestImageUrl, + friendUsername = state.friends?.get(i)?.name, + onUnpin = { + vm.unpinUser(state.friends?.get(i)?.name) + state.user?.username?.let { vm.fetchFriends(it) } + } + ) + } } } } diff --git a/app/src/main/java/io/musicorum/mobile/views/home/HomeState.kt b/app/src/main/java/io/musicorum/mobile/views/home/HomeState.kt index 509fb31..5370e5e 100644 --- a/app/src/main/java/io/musicorum/mobile/views/home/HomeState.kt +++ b/app/src/main/java/io/musicorum/mobile/views/home/HomeState.kt @@ -20,5 +20,6 @@ data class HomeState( val rewindCardMessage: String = "", val showSettingsBade: Boolean = false, val isOffline: Boolean = false, - val friendsActivity: List? = null + val friendsActivity: List? = null, + val pinnedUsers: Set = emptySet() ) diff --git a/app/src/main/java/io/musicorum/mobile/views/home/HomeViewModel.kt b/app/src/main/java/io/musicorum/mobile/views/home/HomeViewModel.kt index 7350377..656650d 100644 --- a/app/src/main/java/io/musicorum/mobile/views/home/HomeViewModel.kt +++ b/app/src/main/java/io/musicorum/mobile/views/home/HomeViewModel.kt @@ -5,6 +5,8 @@ import android.app.Application import android.content.Context import android.content.Intent import android.net.Uri +import androidx.compose.material3.SnackbarHostState +import androidx.datastore.preferences.core.edit import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.crashlytics.ktx.crashlytics @@ -13,6 +15,7 @@ import com.google.firebase.remoteconfig.FirebaseRemoteConfig import dagger.hilt.android.lifecycle.HiltViewModel import io.musicorum.mobile.database.CachedScrobblesDb import io.musicorum.mobile.database.PendingScrobblesDb +import io.musicorum.mobile.datastore.UserData import io.musicorum.mobile.ktor.endpoints.UserEndpoint import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumTrackEndpoint import io.musicorum.mobile.models.CachedScrobble @@ -24,15 +27,18 @@ import io.musicorum.mobile.repositories.ScrobbleRepository import io.musicorum.mobile.serialization.Image import io.musicorum.mobile.serialization.RecentTracks import io.musicorum.mobile.serialization.entities.Track +import io.musicorum.mobile.userData import io.musicorum.mobile.utils.createPalette import io.musicorum.mobile.utils.getBitmap import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.net.UnknownHostException import java.time.Instant import javax.inject.Inject +import io.musicorum.mobile.serialization.UserData as LastFmUser @HiltViewModel class HomeViewModel @Inject constructor( @@ -44,7 +50,7 @@ class HomeViewModel @Inject constructor( val ctx = application as Context private val remoteConfig = FirebaseRemoteConfig.getInstance() val state = MutableStateFlow(HomeState()) - + val snackbarHostState = SnackbarHostState() fun refresh() { state.update { @@ -65,71 +71,92 @@ class HomeViewModel @Inject constructor( ctx.startActivity(intent) } - private fun fetchRecentTracks(username: String, from: String?) { + fun unpinUser(username: String?) { viewModelScope.launch { - val cachedDao = CachedScrobblesDb.getDatabase(ctx).cachedScrobblesDao() - val cacheRepository = CachedScrobblesRepository(cachedDao) - val res = kotlin.runCatching { - val res = UserEndpoint.getRecentTracks(username, from, 15, true) - state.update { state -> - state.copy(isOffline = false) - } - if (res != null) { - val musRes = MusicorumTrackEndpoint.fetchTracks(res.recentTracks.tracks) - res.recentTracks.tracks.onEachIndexed { index, track -> - track.bestImageUrl = - musRes.getOrNull(index)?.bestResource?.bestImageUrl - ?: return@onEachIndexed - } - scrobbleRepository.recentScrobbles.value = res - state.update { - it.copy( - recentTracks = scrobbleRepository.recentScrobbles.value?.recentTracks?.tracks, - weeklyScrobbles = - res.recentTracks.recentTracksAttributes.total.toInt(), - isRefreshing = false - ) + val pinned = ctx.userData.data.map { + it[UserData.PINNED_USERS] ?: emptySet() + }.first() + val newSet = pinned.toMutableSet() + newSet.remove(username) + ctx.userData.edit { + it[UserData.PINNED_USERS] = newSet.toSet() + } + val currentPinned = state.value.pinnedUsers.toMutableSet() + currentPinned.remove(username) + state.update { + it.copy(pinnedUsers = currentPinned.toSet()) + } + snackbarHostState.showSnackbar("$username unpinned") + } + } + + private fun fetchRecentTracks(username: String, from: String?) { + runCatching { + viewModelScope.launch { + val cachedDao = CachedScrobblesDb.getDatabase(ctx).cachedScrobblesDao() + val cacheRepository = CachedScrobblesRepository(cachedDao) + val res = kotlin.runCatching { + val res = UserEndpoint.getRecentTracks(username, from, 15, true) + state.update { state -> + state.copy(isOffline = false) } - cacheRepository.deleteAll() - state.value.recentTracks?.take(10)?.forEach { - cacheRepository.insert( - CachedScrobble( - trackName = it.name, - artistName = it.artist.name, - scrobbleDate = it.date?.uts?.toLong() ?: 0, - imageUrl = it.bestImageUrl, - isTopTrack = false + if (res != null) { + val musRes = MusicorumTrackEndpoint.fetchTracks(res.recentTracks.tracks) + res.recentTracks.tracks.onEachIndexed { index, track -> + track.bestImageUrl = + musRes.getOrNull(index)?.bestResource?.bestImageUrl + ?: return@onEachIndexed + } + scrobbleRepository.recentScrobbles.value = res + state.update { + it.copy( + recentTracks = scrobbleRepository.recentScrobbles.value?.recentTracks?.tracks, + weeklyScrobbles = + res.recentTracks.recentTracksAttributes.total.toInt(), + isRefreshing = false ) - ) + } + cacheRepository.deleteAll() + state.value.recentTracks?.take(10)?.forEach { + cacheRepository.insert( + CachedScrobble( + trackName = it.name, + artistName = it.artist.name, + scrobbleDate = it.date?.uts?.toLong() ?: 0, + imageUrl = it.bestImageUrl, + isTopTrack = false + ) + ) + } } } - } - if (res.exceptionOrNull() is UnknownHostException) { - val pendingDao = PendingScrobblesDb.getDatabase(ctx).pendingScrobblesDao() - val pendingRepo = PendingScrobblesRepository(pendingDao) - val pendingScrobbles = pendingRepo.getAllScrobblesStream().first() - state.update { - it.copy(isOffline = true) - } - val list = mutableListOf() + if (res.exceptionOrNull() is UnknownHostException) { + val pendingDao = PendingScrobblesDb.getDatabase(ctx).pendingScrobblesDao() + val pendingRepo = PendingScrobblesRepository(pendingDao) + val pendingScrobbles = pendingRepo.getAllScrobblesStream().first() + state.update { + it.copy(isOffline = true) + } + val list = mutableListOf() - for (s in pendingScrobbles) { - list.add(s.toTrack()) - } + for (s in pendingScrobbles) { + list.add(s.toTrack()) + } - if (pendingScrobbles.isNotEmpty()) { + if (pendingScrobbles.isNotEmpty()) { + state.update { + it.copy(hasPendingScrobbles = true) + } + } + val cache = cacheRepository.getAllFromCache().first() + cache.forEach { + list.add(it.toTrack()) + } + list.sortByDescending { it.date?.uts } state.update { - it.copy(hasPendingScrobbles = true) + it.copy(recentTracks = list, weeklyScrobbles = 0, isRefreshing = false) } } - val cache = cacheRepository.getAllFromCache().first() - cache.forEach { - list.add(it.toTrack()) - } - list.sortByDescending { it.date?.uts } - state.update { - it.copy(recentTracks = list, weeklyScrobbles = 0, isRefreshing = false) - } } } } @@ -147,65 +174,80 @@ class HomeViewModel @Inject constructor( private fun fetchTopTracks(username: String) { val cachedDao = CachedScrobblesDb.getDatabase(ctx).cachedScrobblesDao() val cachedRepo = CachedScrobblesRepository(cachedDao) - viewModelScope.launch { - val res = kotlin.runCatching { - val topTracksRes = UserEndpoint.getTopTracks(username, FetchPeriod.WEEK, 10) - if (topTracksRes == null) { + runCatching { + viewModelScope.launch { + val res = kotlin.runCatching { + val topTracksRes = UserEndpoint.getTopTracks(username, FetchPeriod.WEEK, 10) + if (topTracksRes == null) { + state.update { + it.copy(hasError = true) + } + return@launch + } + val musicorumTrRes = + MusicorumTrackEndpoint.fetchTracks(topTracksRes.topTracks.tracks) + musicorumTrRes.forEachIndexed { i, track -> + val url = track?.resources?.getOrNull(0)?.bestImageUrl + topTracksRes.topTracks.tracks[i].images = + listOf(Image("unknown", url ?: "")) + topTracksRes.topTracks.tracks[i].bestImageUrl = url ?: "" + } state.update { - it.copy(hasError = true) + it.copy(weekTracks = topTracksRes.topTracks.tracks) } - return@launch - } - val musicorumTrRes = - MusicorumTrackEndpoint.fetchTracks(topTracksRes.topTracks.tracks) - musicorumTrRes.forEachIndexed { i, track -> - val url = track?.resources?.getOrNull(0)?.bestImageUrl - topTracksRes.topTracks.tracks[i].images = listOf(Image("unknown", url ?: "")) - topTracksRes.topTracks.tracks[i].bestImageUrl = url ?: "" - } - state.update { - it.copy(weekTracks = topTracksRes.topTracks.tracks) - } - for (t in topTracksRes.topTracks.tracks) { - cachedRepo.insert( - CachedScrobble( - isTopTrack = true, - scrobbleDate = t.date?.uts?.toLong() ?: 0, - imageUrl = t.bestImageUrl, - artistName = t.artist.name, - trackName = t.name + for (t in topTracksRes.topTracks.tracks) { + cachedRepo.insert( + CachedScrobble( + isTopTrack = true, + scrobbleDate = t.date?.uts?.toLong() ?: 0, + imageUrl = t.bestImageUrl, + artistName = t.artist.name, + trackName = t.name + ) ) - ) + } } - } - if (res.exceptionOrNull() is UnknownHostException) { - val topTracks = cachedRepo.getAllTopsFromCache().first() - val list = mutableListOf() - for (t in topTracks) { - list.add(t.toTrack()) - } - state.update { - it.copy(weekTracks = list) + if (res.exceptionOrNull() is UnknownHostException) { + val topTracks = cachedRepo.getAllTopsFromCache().first() + val list = mutableListOf() + for (t in topTracks) { + list.add(t.toTrack()) + } + state.update { + it.copy(weekTracks = list) + } } } } } - private fun fetchFriends(username: String) { + fun fetchFriends(username: String) { viewModelScope.launch { - val friendsRes = UserEndpoint.getFriends(username, 3) + val pinnedUsers = ctx.userData.data.map { + it[UserData.PINNED_USERS] ?: emptySet() + }.first() + val remaining = 3 - pinnedUsers.size + + val friendsRes = UserEndpoint.getFriends(username, null) if (friendsRes == null) { state.update { it.copy(hasError = true) } return@launch } + val friendsToFetch = mutableListOf() + friendsToFetch.addAll( + friendsRes.friends.users.filter { pinnedUsers.contains(it.name) } + ) + friendsToFetch.addAll( + friendsRes.friends.users.take(remaining) + ) state.update { - it.copy(friends = friendsRes.friends.users) + it.copy(friends = friendsToFetch) } val mutableList: MutableList = mutableListOf() - friendsRes.friends.users.forEach { user -> + friendsToFetch.forEach { user -> val friendRecentAct = UserEndpoint.getRecentTracks(user.name, null, 1, false) friendRecentAct?.let { mutableList.add(it) } @@ -223,6 +265,9 @@ class HomeViewModel @Inject constructor( state.update { it.copy(user = localUser) } + val pinnedUsers = ctx.userData.data.map { + it[UserData.PINNED_USERS] ?: emptySet() + }.first() getPalette(localUser.imageUrl, ctx) fetchRecentTracks(localUser.username, fromTimestamp) fetchTopTracks(localUser.username) @@ -233,7 +278,11 @@ class HomeViewModel @Inject constructor( "Check out this year's Rewind!" } state.update { current -> - current.copy(showRewindCard = rewindEnabled, rewindCardMessage = rewindMessage) + current.copy( + showRewindCard = rewindEnabled, + rewindCardMessage = rewindMessage, + pinnedUsers = pinnedUsers + ) } } diff --git a/app/src/main/java/io/musicorum/mobile/views/individual/User.kt b/app/src/main/java/io/musicorum/mobile/views/individual/user/User.kt similarity index 62% rename from app/src/main/java/io/musicorum/mobile/views/individual/User.kt rename to app/src/main/java/io/musicorum/mobile/views/individual/user/User.kt index e82fb6e..fde9445 100644 --- a/app/src/main/java/io/musicorum/mobile/views/individual/User.kt +++ b/app/src/main/java/io/musicorum/mobile/views/individual/user/User.kt @@ -1,16 +1,25 @@ -package io.musicorum.mobile.views.individual +package io.musicorum.mobile.views.individual.user import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -19,6 +28,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -36,24 +47,21 @@ import io.musicorum.mobile.components.TopArtistsRow import io.musicorum.mobile.components.TrackListItem import io.musicorum.mobile.components.skeletons.GenericListItemSkeleton import io.musicorum.mobile.ui.theme.ContentSecondary +import io.musicorum.mobile.ui.theme.EvenLighterGray import io.musicorum.mobile.ui.theme.Heading4 import io.musicorum.mobile.ui.theme.KindaBlack +import io.musicorum.mobile.ui.theme.LighterGray import io.musicorum.mobile.ui.theme.Subtitle1 import io.musicorum.mobile.ui.theme.Typography import io.musicorum.mobile.utils.LocalSnackbar -import io.musicorum.mobile.viewmodels.UserViewModel @Composable fun User( userViewModel: UserViewModel = viewModel() ) { - val user by userViewModel.user.observeAsState() - - val topArtists by userViewModel.topArtists.observeAsState() - val recentScrobbles = userViewModel.recentTracks.observeAsState().value?.recentTracks?.tracks - val topAlbums by userViewModel.topAlbums.observeAsState() + val state by userViewModel.state.collectAsState() val isRefreshing = - rememberSwipeRefreshState(isRefreshing = userViewModel.isRefreshing.collectAsState().value) + rememberSwipeRefreshState(isRefreshing = state.isRefreshing) val errored by userViewModel.errored.observeAsState() val scrollState = rememberScrollState() val localSnack = LocalSnackbar.current @@ -65,7 +73,7 @@ fun User( } - if (user == null) { + if (state.user == null) { CenteredLoadingSpinner() } else { SwipeRefresh(state = isRefreshing, onRefresh = { userViewModel.refresh() }) { @@ -77,15 +85,15 @@ fun User( .padding(bottom = 20.dp) ) { GradientHeader( - backgroundUrl = topArtists?.topArtists?.artists?.getOrNull(0)?.bestImageUrl, - coverUrl = user?.user?.bestImageUrl, + backgroundUrl = state.topArtists?.getOrNull(0)?.bestImageUrl, + coverUrl = state.user?.user?.bestImageUrl, shape = CircleShape, placeholderType = PlaceholderType.USER ) - Text(text = user?.user!!.name, style = Typography.displaySmall) + Text(text = state.user?.user!!.name, style = Typography.displaySmall) Row { - user?.user!!.realName?.let { + state.user?.user!!.realName?.let { Text( text = "$it • ", color = ContentSecondary, @@ -95,19 +103,59 @@ fun User( Text( text = stringResource( R.string.scrobbling_since, - user?.user?.registered?.asParsedDate ?: "" + state.user?.user?.registered?.asParsedDate ?: "" ), style = Typography.bodyLarge, color = ContentSecondary ) } + Box( + modifier = Modifier + .padding(top = 10.dp) + .background(LighterGray, RoundedCornerShape(28.dp)) + .border(1.dp, EvenLighterGray, RoundedCornerShape(28.dp)) + .clip(RoundedCornerShape(28.dp)) + .clickable(enabled = state.canPin) { userViewModel.updatePin() } + .padding(vertical = 5.dp, horizontal = 10.dp) + + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (state.isPinned) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Text("Pinned", modifier = Modifier.padding(start = 5.dp)) + } else { + Icon( + tint = if (state.canPin) Color.White else EvenLighterGray, + imageVector = Icons.Outlined.PushPin, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + if (state.canPin) { + Text( + "Pin on home screen", + modifier = Modifier.padding(start = 5.dp) + ) + } else { + Text( + "Max reached (3/3)", + modifier = Modifier.padding(start = 5.dp), + color = EvenLighterGray + ) + } + } + } + } HorizontalDivider(modifier = Modifier.run { padding(vertical = 20.dp) }) StatisticRow( short = false, - stringResource(R.string.scrobbles) to user?.user?.scrobbles?.toLong(), - stringResource(R.string.artists) to user?.user?.artistCount?.toLongOrNull(), - stringResource(R.string.albums) to user?.user?.albumCount?.toLongOrNull() + stringResource(R.string.scrobbles) to state.user?.user?.scrobbles?.toLong(), + stringResource(R.string.artists) to state.user?.user?.artistCount?.toLongOrNull(), + stringResource(R.string.albums) to state.user?.user?.albumCount?.toLongOrNull() ) HorizontalDivider(modifier = Modifier.run { padding(vertical = 20.dp) }) @@ -118,14 +166,14 @@ fun User( .fillMaxWidth() .padding(horizontal = 20.dp) ) - if (recentScrobbles == null) { + if (state.recentTracks == null) { GenericListItemSkeleton(visible = true) GenericListItemSkeleton(visible = true) GenericListItemSkeleton(visible = true) } else { - recentScrobbles.let { track -> - track.forEach { + state.recentTracks.let { track -> + track?.forEach { TrackListItem( track = it, favoriteIcon = false, @@ -147,7 +195,7 @@ fun User( .padding(start = 20.dp) .fillMaxWidth() ) - if (topArtists?.topArtists?.artists.isNullOrEmpty()) { + if (state.topArtists.isNullOrEmpty()) { Text( text = stringResource(id = R.string.no_data_available), textAlign = TextAlign.Start, @@ -156,7 +204,7 @@ fun User( ) } else { Spacer(modifier = Modifier.height(10.dp)) - TopArtistsRow(artists = topArtists!!.topArtists.artists) + TopArtistsRow(artists = state.topArtists!!) } /* TOP ALBUMS */ @@ -170,16 +218,16 @@ fun User( .padding(start = 20.dp) .fillMaxWidth() ) - if (!topAlbums?.topAlbums?.albums.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(10.dp)) - TopAlbumsRow(albums = topAlbums!!.topAlbums.albums) - } else { + if (state.topAlbums.isNullOrEmpty()) { Text( text = stringResource(id = R.string.no_data_available), textAlign = TextAlign.Start, style = Subtitle1, modifier = Modifier.padding(vertical = 20.dp) ) + } else { + Spacer(modifier = Modifier.height(10.dp)) + TopAlbumsRow(albums = state.topAlbums!!) } } } diff --git a/app/src/main/java/io/musicorum/mobile/views/individual/user/UserState.kt b/app/src/main/java/io/musicorum/mobile/views/individual/user/UserState.kt new file mode 100644 index 0000000..0b0bc72 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/views/individual/user/UserState.kt @@ -0,0 +1,18 @@ +package io.musicorum.mobile.views.individual.user + +import io.musicorum.mobile.serialization.TopAlbum +import io.musicorum.mobile.serialization.User +import io.musicorum.mobile.serialization.entities.TopArtist +import io.musicorum.mobile.serialization.entities.Track + +data class UserState( + val user: User? = null, + val topArtists: List? = null, + val recentTracks: List? = null, + val topAlbums: List? = null, + val isRefreshing: Boolean = false, + val hasError: Boolean = false, + val isPinned: Boolean = false, + val showPin: Boolean = true, + val canPin: Boolean = true, +) diff --git a/app/src/main/java/io/musicorum/mobile/views/individual/user/UserViewModel.kt b/app/src/main/java/io/musicorum/mobile/views/individual/user/UserViewModel.kt new file mode 100644 index 0000000..0c71f0a --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/views/individual/user/UserViewModel.kt @@ -0,0 +1,137 @@ +package io.musicorum.mobile.views.individual.user + +import android.app.Application +import androidx.datastore.preferences.core.edit +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import io.musicorum.mobile.datastore.UserData +import io.musicorum.mobile.ktor.endpoints.UserEndpoint +import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumArtistEndpoint +import io.musicorum.mobile.models.FetchPeriod +import io.musicorum.mobile.repositories.LocalUserRepository +import io.musicorum.mobile.userData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class UserViewModel @Inject constructor( + application: Application, + savedStateHandle: SavedStateHandle +) : AndroidViewModel(application) { + val state = MutableStateFlow(UserState()) + val errored by lazy { MutableLiveData(false) } + val ctx = application + private val usernameArg = savedStateHandle.get("usernameArg") + private val localUser = LocalUserRepository(ctx) + + fun refresh() { + state.update { + it.copy(isRefreshing = true, recentTracks = null) + } + getRecentTracks(limit = 4, null) + } + + fun getUser() = kotlin.runCatching { + viewModelScope.launch { + if (usernameArg == null) { + val user = localUser.fetch() + state.update { + it.copy(user = user) + } + } else { + val userInfo = UserEndpoint.getUser(usernameArg) + state.update { + it.copy(user = userInfo, hasError = userInfo == null) + } + } + } + } + + private fun getTopArtists(limit: Int?, period: FetchPeriod?) = kotlin.runCatching { + viewModelScope.launch { + val username = usernameArg ?: localUser.getUser().username + val res = UserEndpoint.getTopArtists(username, limit, period) + if (res != null) { + val musRes = + MusicorumArtistEndpoint.fetchArtist(res.topArtists.artists) + musRes.forEachIndexed { index, trackResponse -> + val trackImageUrl = trackResponse.resources.getOrNull(0)?.bestImageUrl ?: "" + res.topArtists.artists[index].bestImageUrl = trackImageUrl + } + state.update { + it.copy(topArtists = res.topArtists.artists) + } + } + } + } + + private fun getRecentTracks(limit: Int?, extended: Boolean?) = kotlin.runCatching { + viewModelScope.launch { + val username = usernameArg ?: localUser.getUser().username + val res = UserEndpoint.getRecentTracks(username, null, limit, extended) + state.update { + it.copy(recentTracks = res?.recentTracks?.tracks, isRefreshing = false) + } + } + } + + private fun getTopAlbums(period: FetchPeriod?, limit: Int?) = kotlin.runCatching { + viewModelScope.launch { + val username = usernameArg ?: localUser.getUser().username + val res = UserEndpoint.getTopAlbums(username, period, limit) + state.update { + it.copy(topAlbums = res?.topAlbums?.albums) + } + } + } + + private fun updatePinState() { + if (usernameArg == null) { + state.update { + it.copy(showPin = false) + } + } else { + viewModelScope.launch { + val pinnedUsers = ctx.userData.data.map { + it[UserData.PINNED_USERS] + }.first() ?: emptySet() + state.update { + it.copy(isPinned = usernameArg in pinnedUsers, canPin = pinnedUsers.size < 3) + } + } + } + } + + fun updatePin() { + viewModelScope.launch { + val currentPinned = ctx.userData.data.map { + it[UserData.PINNED_USERS] + }.first() ?: emptySet() + + val newPinned = if (usernameArg in currentPinned) { + currentPinned - usernameArg!! + } else { + currentPinned + usernameArg!! + } + ctx.userData.edit { + it[UserData.PINNED_USERS] = newPinned + } + state.update { + it.copy(isPinned = usernameArg in newPinned) + } + } + } + + init { + getUser() + getTopArtists(null, FetchPeriod.MONTH) + getRecentTracks(limit = 4, null) + getTopAlbums(FetchPeriod.MONTH, null) + updatePinState() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/scrobbling/Scrobbling.kt b/app/src/main/java/io/musicorum/mobile/views/scrobbling/Scrobbling.kt index 22dbc9e..18f7d7a 100644 --- a/app/src/main/java/io/musicorum/mobile/views/scrobbling/Scrobbling.kt +++ b/app/src/main/java/io/musicorum/mobile/views/scrobbling/Scrobbling.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -61,6 +62,7 @@ import io.musicorum.mobile.R import io.musicorum.mobile.coil.defaultImageRequestBuilder import io.musicorum.mobile.components.CenteredLoadingSpinner import io.musicorum.mobile.components.TrackListItem +import io.musicorum.mobile.router.BottomNavBar import io.musicorum.mobile.serialization.entities.Track import io.musicorum.mobile.ui.theme.EvenLighterGray import io.musicorum.mobile.ui.theme.KindaBlack @@ -85,38 +87,40 @@ fun Scrobbling(vm: ScrobblingViewModel = hiltViewModel()) { val clamped = interpolated.coerceIn(0f..1f) val value = animateFloatAsState(if (firstItemIndex.value == 0) clamped else 0f, label = "") - - if (viewModelState.recentScrobbles == null) { - CenteredLoadingSpinner() - } else { - Column( - modifier = Modifier - .background(KindaBlack) - .padding(top = 20.dp) - .fillMaxSize() - ) { - Text( - text = "Scrobbling", - style = Typography.displaySmall, - modifier = Modifier.padding(bottom = 20.dp, start = 20.dp) - ) - Column { - NowPlayingCard( - track = viewModelState.playingTrack, - fraction = value.value, - playingApp = viewModelState.scrobblingAppName, - playingAppIcon = viewModelState.scrobblingAppIcon, - loved = viewModelState.isTrackLoved - ) { - vm.updateFavorite(viewModelState.playingTrack, viewModelState.isTrackLoved) + Scaffold(bottomBar = { BottomNavBar() }) { pv -> + if (viewModelState.recentScrobbles == null) { + CenteredLoadingSpinner() + } else { + Column( + modifier = Modifier + .background(KindaBlack) + .padding(pv) + .padding(top = 20.dp) + .fillMaxSize() + ) { + Text( + text = "Scrobbling", + style = Typography.displaySmall, + modifier = Modifier.padding(bottom = 20.dp, start = 20.dp) + ) + Column { + NowPlayingCard( + track = viewModelState.playingTrack, + fraction = value.value, + playingApp = viewModelState.scrobblingAppName, + playingAppIcon = viewModelState.scrobblingAppIcon, + loved = viewModelState.isTrackLoved + ) { + vm.updateFavorite(viewModelState.playingTrack, viewModelState.isTrackLoved) + } } + TrackList( + isRefreshing = viewModelState.isRefreshing, + state = state, + onRefresh = { vm.updateScrobbles() }, + tracks = viewModelState.recentScrobbles + ) } - TrackList( - isRefreshing = viewModelState.isRefreshing, - state = state, - onRefresh = { vm.updateScrobbles() }, - tracks = viewModelState.recentScrobbles - ) } } } diff --git a/app/src/main/java/io/musicorum/mobile/views/scrobbling/ScrobblingViewModel.kt b/app/src/main/java/io/musicorum/mobile/views/scrobbling/ScrobblingViewModel.kt index 4997a97..7491935 100644 --- a/app/src/main/java/io/musicorum/mobile/views/scrobbling/ScrobblingViewModel.kt +++ b/app/src/main/java/io/musicorum/mobile/views/scrobbling/ScrobblingViewModel.kt @@ -119,7 +119,7 @@ class ScrobblingViewModel @Inject constructor( } } - private fun getMediaSessionPackage() { + private fun getMediaSessionPackage() = runCatching { val mediaService = ctx.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager val component = ComponentName(ctx, NotificationListener::class.java) diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml index 0617877..cc43a89 100644 --- a/app/src/main/res/drawable/splash_background.xml +++ b/app/src/main/res/drawable/splash_background.xml @@ -12,7 +12,7 @@ \ No newline at end of file diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index 2077308..f81baa4 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -142,7 +142,7 @@ Tracce dell\'album Impossibile recuperare l\'album Qualcosa è andato storto - Scrobbling from %1$s + Scrobbling dal %1$s Abilitato • %1$d app Abilitato • %1$d app diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index ce13dd5..119667e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,17 +1,12 @@ - -