better app picking experience, minor updates

This commit is contained in:
lone-cloud 2026-02-16 01:26:17 -08:00
parent a4cd95835c
commit 1de7918203
8 changed files with 135 additions and 22 deletions

1
.gitignore vendored
View file

@ -16,3 +16,4 @@ local.properties
.idea .idea
prism-release.keystore prism-release.keystore
.kotlin/sessions/kotlin-compiler-* .kotlin/sessions/kotlin-compiler-*
.VSCodeCounter

View file

@ -56,6 +56,8 @@ class MainViewModel(
var selectedApp by mutableStateOf<InstalledApp?>(null) var selectedApp by mutableStateOf<InstalledApp?>(null)
private set private set
var prefilledName by mutableStateOf<String?>(null)
fun updatePrismServerConfigured(configured: Boolean) { fun updatePrismServerConfigured(configured: Boolean) {
mainUiState = mainUiState.copy(prismServerConfigured = configured) mainUiState = mainUiState.copy(prismServerConfigured = configured)
} }

View file

@ -32,19 +32,25 @@ import app.lonecloud.prism.R
@Composable @Composable
fun AddAppScreen( fun AddAppScreen(
selectedApp: InstalledApp?, selectedApp: InstalledApp?,
prefilledName: String? = null,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToAppPicker: () -> Unit, onNavigateToAppPicker: () -> Unit,
onConfirm: (name: String, packageName: String, description: String?) -> Unit onConfirm: (name: String, packageName: String, description: String?) -> Unit
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf(prefilledName ?: "") }
// Auto-fill name when app is selected
androidx.compose.runtime.LaunchedEffect(selectedApp) { androidx.compose.runtime.LaunchedEffect(selectedApp) {
if (name.isBlank() && selectedApp != null) { if (name.isBlank() && selectedApp != null) {
name = selectedApp.appName name = selectedApp.appName
} }
} }
androidx.compose.runtime.LaunchedEffect(prefilledName) {
if (prefilledName != null && name != prefilledName) {
name = prefilledName
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View file

@ -27,20 +27,61 @@ import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import app.lonecloud.prism.R import app.lonecloud.prism.R
data class PrismServerApp(val name: String, val matchedInstalledApp: InstalledApp? = null)
@Composable @Composable
fun AppPickerScreen( fun AppPickerScreen(
apps: List<InstalledApp>, apps: List<InstalledApp>,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onSelect: (InstalledApp) -> Unit onSelect: (InstalledApp) -> Unit,
onSelectPrismApp: ((String) -> Unit)? = null
) { ) {
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
var prismServerApps by remember { mutableStateOf<List<PrismServerApp>>(emptyList()) }
var prismAppsLoaded by remember { mutableStateOf(false) }
var showContent by remember { mutableStateOf(false) }
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) {
kotlinx.coroutines.delay(100)
showContent = true
}
val recommendedPackages = listOf( val recommendedPackages = listOf(
"ch.protonmail.android", "ch.protonmail.android",
"io.homeassistant.companion.android" "io.homeassistant.companion.android"
) )
val (recommendedApps, otherApps) = remember(apps, searchQuery) { androidx.compose.runtime.LaunchedEffect(onSelectPrismApp) {
if (onSelectPrismApp != null) {
app.lonecloud.prism.PrismServerClient.fetchRegisteredApps(
context,
onSuccess = { serverAppNames ->
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val localAppNames = db.listApps()
.filter { it.description?.startsWith("target:") == true }
.mapNotNull { it.title }
.toSet()
prismServerApps = serverAppNames
.filterNot { it in localAppNames }
.map { serverAppName ->
val matchedApp = apps.find {
it.appName.equals(serverAppName, ignoreCase = true)
}
PrismServerApp(serverAppName, matchedApp)
}
prismAppsLoaded = true
},
onError = {
prismAppsLoaded = true
}
)
} else {
prismAppsLoaded = true
}
}
val (recommendedApps, otherApps) = remember(apps, searchQuery, prismServerApps) {
val filtered = if (searchQuery.isBlank()) { val filtered = if (searchQuery.isBlank()) {
apps apps
} else { } else {
@ -50,12 +91,14 @@ fun AppPickerScreen(
} }
} }
val prismAppNames = prismServerApps.map { it.name }
val recommended = filtered.filter { app -> val recommended = filtered.filter { app ->
recommendedPackages.any { pkg -> app.packageName.startsWith(pkg) } recommendedPackages.any { pkg -> app.packageName.startsWith(pkg) } &&
} !prismAppNames.contains(app.appName)
}.sortedBy { it.appName.lowercase() }
val others = filtered.filterNot { app -> val others = filtered.filterNot { app ->
recommendedPackages.any { pkg -> app.packageName.startsWith(pkg) } recommendedPackages.any { pkg -> app.packageName.startsWith(pkg) }
} }.sortedBy { it.appName.lowercase() }
recommended to others recommended to others
} }
@ -79,7 +122,31 @@ fun AppPickerScreen(
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (recommendedApps.isNotEmpty()) { if (showContent && prismAppsLoaded && prismServerApps.isNotEmpty() && onSelectPrismApp != null && searchQuery.isBlank()) {
item {
Text(
text = stringResource(R.string.from_your_server),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp)
)
}
items(prismServerApps) { prismApp ->
PrismAppListItem(
prismApp = prismApp,
onClick = {
if (prismApp.matchedInstalledApp != null) {
onSelect(prismApp.matchedInstalledApp)
onNavigateBack()
} else {
searchQuery = prismApp.name
}
}
)
}
}
if (showContent && recommendedApps.isNotEmpty()) {
item { item {
Text( Text(
text = stringResource(R.string.recommended_apps), text = stringResource(R.string.recommended_apps),
@ -100,8 +167,8 @@ fun AppPickerScreen(
} }
} }
if (otherApps.isNotEmpty()) { if (showContent && otherApps.isNotEmpty()) {
if (recommendedApps.isNotEmpty()) { if (recommendedApps.isNotEmpty() || prismServerApps.isNotEmpty()) {
item { item {
Text( Text(
text = stringResource(R.string.all_apps), text = stringResource(R.string.all_apps),
@ -162,3 +229,36 @@ private fun AppListItem(
} }
} }
} }
@Composable
private fun PrismAppListItem(prismApp: PrismServerApp, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
val icon = prismApp.matchedInstalledApp?.icon
if (icon != null) {
val bitmap = icon.toBitmap(48, 48)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
} else {
Image(
painter = androidx.compose.ui.res.painterResource(R.drawable.app_logo),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
}
Text(
text = prismApp.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary
)
}
}

View file

@ -104,9 +104,7 @@ fun App(
Scaffold( Scaffold(
topBar = { topBar = {
if (currentScreen == AppScreen.Intro) { if (currentScreen != AppScreen.Intro) {
null
} else {
when (currentScreen) { when (currentScreen) {
AppScreen.Main -> { AppScreen.Main -> {
MainAppBarOrSelection( MainAppBarOrSelection(
@ -125,8 +123,11 @@ fun App(
} }
}, },
floatingActionButton = { floatingActionButton = {
val prefs = PrismPreferences(context) val serverPrefs = PrismPreferences(context)
if (currentScreen == AppScreen.Main && !prefs.prismServerUrl.isNullOrBlank() && !prefs.prismApiKey.isNullOrBlank()) { if (currentScreen == AppScreen.Main &&
!serverPrefs.prismServerUrl.isNullOrBlank() &&
!serverPrefs.prismApiKey.isNullOrBlank()
) {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
mainViewModel.clearSelectedApp() mainViewModel.clearSelectedApp()
@ -253,12 +254,14 @@ fun App(
) { ) {
AddAppScreen( AddAppScreen(
selectedApp = mainViewModel.selectedApp, selectedApp = mainViewModel.selectedApp,
prefilledName = mainViewModel.prefilledName,
onNavigateBack = { navController.navigateUp() }, onNavigateBack = { navController.navigateUp() },
onNavigateToAppPicker = { onNavigateToAppPicker = {
navController.navigate(AppScreen.AppPicker.name) navController.navigate(AppScreen.AppPicker.name)
}, },
onConfirm = { name, packageName, description -> onConfirm = { name, packageName, description ->
mainViewModel.addManualApp(name, packageName, description) mainViewModel.addManualApp(name, packageName, description)
mainViewModel.prefilledName = null
} }
) )
} }
@ -273,6 +276,9 @@ fun App(
onNavigateBack = { navController.navigateUp() }, onNavigateBack = { navController.navigateUp() },
onSelect = { app -> onSelect = { app ->
mainViewModel.selectApp(app) mainViewModel.selectApp(app)
},
onSelectPrismApp = { appName ->
mainViewModel.prefilledName = appName
} }
) )
} }

View file

@ -25,7 +25,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -42,7 +41,6 @@ fun IntroScreen(onComplete: (url: String, apiKey: String) -> Unit, onSkip: () ->
var apiKey by remember { mutableStateOf("") } var apiKey by remember { mutableStateOf("") }
var isTesting by remember { mutableStateOf(false) } var isTesting by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<String?>(null) } var testResult by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val successMessage = stringResource(R.string.connection_successful) val successMessage = stringResource(R.string.connection_successful)
val failedMessageTemplate = stringResource(R.string.connection_failed) val failedMessageTemplate = stringResource(R.string.connection_failed)

View file

@ -160,7 +160,6 @@ fun ServerConfigScreen(
} }
val normalizedUrl = normalizeUrl(url) val normalizedUrl = normalizeUrl(url)
val finalApiKey = apiKey.ifBlank { initialApiKey }
val isServerChanging = initialUrl.isNotBlank() && normalizedUrl != initialUrl val isServerChanging = initialUrl.isNotBlank() && normalizedUrl != initialUrl
if (isServerChanging) { if (isServerChanging) {

View file

@ -34,8 +34,8 @@
<!-- Prism-specific strings --> <!-- Prism-specific strings -->
<string name="app_name">Prism</string> <string name="app_name">Prism</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="add_custom_app_title">Add App</string> <string name="add_custom_app_title">Add New App</string>
<string name="add_app_description">Register an app to receive notifications from your Prism server. Optionally choose a target app to deliver notifications to.</string> <string name="add_app_description">Add an app to receive notifications from your Prism server. Optionally choose a target app to deliver notifications to.</string>
<string name="app_name_label">App Name</string> <string name="app_name_label">App Name</string>
<string name="app_name_placeholder">Enter app name</string> <string name="app_name_placeholder">Enter app name</string>
<string name="target_app_label">Target App (Optional)</string> <string name="target_app_label">Target App (Optional)</string>
@ -44,13 +44,14 @@
<string name="cancel_button">Cancel</string> <string name="cancel_button">Cancel</string>
<string name="select_target_app_title">Select Target App</string> <string name="select_target_app_title">Select Target App</string>
<string name="recommended_apps">Recommended</string> <string name="recommended_apps">Recommended</string>
<string name="from_your_server">From Your Prism Server</string>
<string name="all_apps">All Apps</string> <string name="all_apps">All Apps</string>
<string name="search_apps_label">Search</string> <string name="search_apps_label">Search</string>
<string name="search_apps_placeholder">Search for apps</string> <string name="search_apps_placeholder">Search for apps</string>
<string name="add_manual_app_content_description">Add manual app</string> <string name="add_manual_app_content_description">Add manual app</string>
<string name="registered_apps_heading">Registered Apps</string> <string name="registered_apps_heading">Registered Apps</string>
<string name="debug_title">Debug Information</string> <string name="debug_title">Debug Information</string>
<string name="configure_server">Configure Prism Server</string> <string name="configure_server">Configure Prism server</string>
<string name="prism_server_info">Prism server enables manual app registrations and must be self-hosted.</string> <string name="prism_server_info">Prism server enables manual app registrations and must be self-hosted.</string>
<string name="prism_server_learn_more">Learn more about Prism</string> <string name="prism_server_learn_more">Learn more about Prism</string>
<string name="prism_server_configured">Prism server configured</string> <string name="prism_server_configured">Prism server configured</string>
@ -70,7 +71,7 @@
<string name="clear_server_confirm_message_with_apps">You have %d manual app registration(s). Clearing the server will delete them from the server and remove the configuration.</string> <string name="clear_server_confirm_message_with_apps">You have %d manual app registration(s). Clearing the server will delete them from the server and remove the configuration.</string>
<string name="app_dropdown_show_toasts">Notify when apps register</string> <string name="app_dropdown_show_toasts">Notify when apps register</string>
<string name="show_toasts_description">Show a notification when apps register or unregister</string> <string name="show_toasts_description">Show a notification when apps register or unregister</string>
<string name="dynamic_colors_title">Dynamic Colors</string> <string name="dynamic_colors_title">Dynamic colors</string>
<string name="dynamic_colors_description">Use colors from your wallpaper</string> <string name="dynamic_colors_description">Use colors from your wallpaper</string>
<!-- Intro screen strings --> <!-- Intro screen strings -->