feat(profile): add profile selection logic and add profile creation option

This commit is contained in:
tapframe 2026-04-02 16:59:26 +05:30
parent 254d912f3c
commit 511804baab
6 changed files with 138 additions and 44 deletions

View file

@ -212,9 +212,11 @@ fun App() {
}
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var isNewProfile by remember { mutableStateOf(false) }
var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(authState) {
when (authState) {
@ -227,12 +229,36 @@ fun App() {
val authenticatedState = authState as AuthState.Authenticated
ProfileRepository.ensureLoaded(authenticatedState.userId)
if (gateScreen == AppGateScreen.Loading.name || gateScreen == AppGateScreen.Auth.name) {
gateScreen = AppGateScreen.ProfileSelection.name
autoSkipProfileSelection = true
val cachedProfiles = ProfileRepository.state.value.profiles
if (cachedProfiles.size == 1) {
val onlyProfile = cachedProfiles.first()
ProfileRepository.selectProfile(onlyProfile.profileIndex)
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
} else {
gateScreen = AppGateScreen.ProfileSelection.name
}
}
}
}
}
LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) {
if (
autoSkipProfileSelection &&
gateScreen == AppGateScreen.ProfileSelection.name &&
profileState.profiles.size == 1
) {
val onlyProfile = profileState.profiles.first()
ProfileRepository.selectProfile(onlyProfile.profileIndex)
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
}
}
AnimatedContent(
targetState = gateScreen,
label = "app_gate",
@ -286,6 +312,7 @@ fun App() {
AppGateScreen.Main.name -> {
MainAppContent(
onSwitchProfile = {
autoSkipProfileSelection = false
gateScreen = AppGateScreen.ProfileSelection.name
},
)
@ -478,6 +505,7 @@ private fun MainAppContent(
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
},
label = { Text("Profile") },
@ -521,6 +549,7 @@ private fun MainAppContent(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
}
}
@ -966,6 +995,7 @@ private fun TabletFloatingTopBar(
selectedTab: AppScreenTab,
onTabSelected: (AppScreenTab) -> Unit,
onProfileSelected: (NuvioProfile) -> Unit,
onAddProfileRequested: () -> Unit,
modifier: Modifier = Modifier,
) {
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
@ -1040,6 +1070,7 @@ private fun TabletFloatingTopBar(
selected = selectedTab == AppScreenTab.Settings,
onClick = { onTabSelected(AppScreenTab.Settings) },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onAddProfileRequested,
)
Text(
text = "Profile",

View file

@ -11,7 +11,7 @@ internal object PluginManifestParser {
val manifest = json.decodeFromString<PluginManifest>(payload)
require(manifest.name.isNotBlank()) { "Manifest name is missing." }
require(manifest.version.isNotBlank()) { "Manifest version is missing." }
require(manifest.scrapers.isNotEmpty()) { "Manifest has no scrapers." }
require(manifest.scrapers.isNotEmpty()) { "Manifest has no providers." }
return manifest
}
}

View file

@ -293,7 +293,7 @@ object PluginRepository {
suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> {
initialize()
val scraper = _uiState.value.scrapers.find { it.id == scraperId }
?: return Result.failure(IllegalArgumentException("Scraper not found"))
?: return Result.failure(IllegalArgumentException("Provider not found"))
val mediaType = if (scraper.supportsType("movie")) "movie" else "tv"
val season = if (mediaType == "tv") 1 else null

View file

@ -84,7 +84,7 @@ fun PluginsSettingsPageContent(
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioInfoBadge(text = "${sortedRepos.size} repos")
NuvioInfoBadge(text = "${sortedScrapers.size} scrapers")
NuvioInfoBadge(text = "${sortedScrapers.size} providers")
NuvioInfoBadge(
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
)
@ -97,13 +97,13 @@ fun PluginsSettingsPageContent(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Enable plugin scrapers globally",
text = "Enable plugin providers globally",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Use JavaScript plugin scrapers during stream discovery.",
text = "Use plugin providers during stream discovery.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -132,7 +132,7 @@ fun PluginsSettingsPageContent(
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "In Streams, show one provider per repo (example: Tapframe) instead of one per scraper.",
text = "In Streams, show one provider per repository instead of one per source.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -201,7 +201,7 @@ fun PluginsSettingsPageContent(
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Add a repository URL to download JS scrapers and use them in stream discovery.",
text = "Add a repository URL to install provider plugins for stream discovery.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -259,7 +259,7 @@ fun PluginsSettingsPageContent(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioInfoBadge(text = "${repo.scraperCount} scrapers")
NuvioInfoBadge(text = "${repo.scraperCount} providers")
if (repo.isRefreshing) {
NuvioInfoBadge(text = "Refreshing")
}
@ -276,11 +276,11 @@ fun PluginsSettingsPageContent(
}
}
NuvioSectionLabel("SCRAPERS")
NuvioSectionLabel("PROVIDERS")
if (sortedScrapers.isEmpty()) {
NuvioSurfaceCard {
Text(
text = "No scrapers available yet.",
text = "No providers available yet.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -354,7 +354,7 @@ fun PluginsSettingsPageContent(
Spacer(modifier = Modifier.height(12.dp))
NuvioPrimaryButton(
text = if (isTestingThisScraper) "Testing..." else "Test Scraper",
text = if (isTestingThisScraper) "Testing..." else "Test Provider",
enabled = !isTestingThisScraper,
onClick = {
testingScraperId = scraper.id
@ -367,7 +367,7 @@ fun PluginsSettingsPageContent(
testResults[scraper.id] = listOf(
PluginRuntimeResult(
title = "Error",
name = error.message ?: "Scraper test failed",
name = error.message ?: "Provider test failed",
url = "about:error",
),
)

View file

@ -31,6 +31,7 @@ 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.Backspace
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.Icon
@ -71,6 +72,7 @@ fun ProfileSwitcherTab(
selected: Boolean,
onClick: () -> Unit,
onProfileSelected: (NuvioProfile) -> Unit,
onAddProfileRequested: () -> Unit,
modifier: Modifier = Modifier,
) {
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
@ -133,7 +135,7 @@ fun ProfileSwitcherTab(
detectTapGestures(
onTap = { onClick() },
onLongPress = {
if (profiles.size > 1) {
if (profiles.isNotEmpty()) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showPopup = true
}
@ -151,7 +153,7 @@ fun ProfileSwitcherTab(
)
// Floating profile popup (stays composed during exit animation)
if (popupVisible && profiles.size > 1) {
if (popupVisible && profiles.isNotEmpty()) {
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, with(density) { -64.dp.roundToPx() }),
@ -202,6 +204,16 @@ fun ProfileSwitcherTab(
},
)
}
if (profiles.size < 4) {
PopupAddProfileBubble(
delayMs = profiles.size * 50,
onClick = {
showPopup = false
onAddProfileRequested()
},
)
}
}
// Inline PIN entry for locked profiles
@ -236,6 +248,74 @@ fun ProfileSwitcherTab(
}
}
@Composable
private fun PopupAddProfileBubble(
delayMs: Int,
onClick: () -> Unit,
) {
val itemAlpha = remember { Animatable(0f) }
val itemScale = remember { Animatable(0.4f) }
LaunchedEffect(Unit) {
delay(delayMs.toLong())
launch { itemAlpha.animateTo(1f, tween(200, easing = FastOutSlowInEasing)) }
launch {
itemScale.animateTo(
1f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.graphicsLayer {
alpha = itemAlpha.value
scaleX = itemScale.value
scaleY = itemScale.value
}
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(4.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(1.5.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = "Add Profile",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Add",
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier.width(56.dp),
)
}
}
@Composable
private fun PopupProfileBubble(
profile: NuvioProfile,

View file

@ -2,13 +2,10 @@ package com.nuvio.app.features.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -33,13 +30,8 @@ internal fun LazyListScope.traktSettingsContent(
uiState: TraktAuthUiState,
) {
item {
SettingsSection(
title = "TRAKT",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
TraktBrandIntro(isTablet = isTablet)
}
SettingsGroup(isTablet = isTablet) {
TraktBrandIntro(isTablet = isTablet)
}
}
@ -69,35 +61,26 @@ private fun TraktBrandIntro(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.Start,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(if (isTablet) 64.dp else 56.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
androidx.compose.foundation.Image(
painter = traktBrandPainter(TraktBrandAsset.Glyph),
contentDescription = "Trakt",
modifier = Modifier.size(if (isTablet) 56.dp else 48.dp),
modifier = Modifier.size(if (isTablet) 84.dp else 72.dp),
contentScale = ContentScale.Fit,
)
androidx.compose.foundation.Image(
painter = traktBrandPainter(TraktBrandAsset.Wordmark),
contentDescription = "Trakt",
modifier = Modifier
.fillMaxHeight()
.width(if (isTablet) 170.dp else 150.dp),
contentScale = ContentScale.Fit,
Text(
text = "Track what you watch, save to watchlist or custom lists, and keep your library synced with Trakt.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "Track what you watch, save to watchlist or custom lists, and keep your library synced with Trakt.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}