diff --git a/.gitignore b/.gitignore index dc6d3ced..05373ccf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.aar *.ap_ *.aab +*.dm # Files for the ART/Dalvik VM *.dex diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index ec46169e..9a50ce2c 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + @@ -23,6 +27,7 @@ android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" /> + diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/base/ProvideSettings.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/base/ProvideSettings.kt index bce0eb64..2ae2d0ec 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/base/ProvideSettings.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/base/ProvideSettings.kt @@ -13,7 +13,6 @@ import de.mm20.launcher2.widgets.FavoritesWidget import de.mm20.launcher2.widgets.WidgetRepository import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import org.koin.androidx.compose.inject @Composable diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt index 99896260..5fda551d 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState @@ -21,6 +22,7 @@ fun BottomSheetDialog( content: @Composable (paddingValues: PaddingValues) -> Unit, ) { ModalBottomSheet( + modifier = Modifier.statusBarsPadding().padding(top = 8.dp), sheetState = bottomSheetState, onDismissRequest = onDismissRequest, ) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt index 35ba8416..a43e540a 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt @@ -1,6 +1,7 @@ package de.mm20.launcher2.ui.ktx import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import hct.Hct import kotlin.math.atan2 import kotlin.math.roundToInt @@ -20,6 +21,14 @@ fun Color.Companion.hct(hue: Float, chroma: Float, tone: Float): Color { return Color(hct.toInt()) } +fun Color.atTone(tone: Int): Color { + return Color( + Hct.fromInt(this.toArgb()).apply { + this.tone = tone.toDouble() + }.toInt() + ) +} + val Color.hue: Float get() { val r = this.red / 255f @@ -28,3 +37,5 @@ val Color.hue: Float // sqrt(3) return atan2(1.7320508f * (g - b), 2f * r - g - b) } + +fun android.graphics.Color.toComposeColor() = Color(this.toArgb()) \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt index ba6f10fa..1c9ed1dc 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -64,6 +64,7 @@ fun SearchColumn( ) { val columns = LocalGridSettings.current.columnCount + val showList = LocalGridSettings.current.showList val context = LocalContext.current val viewModel: SearchVM = viewModel() @@ -93,6 +94,7 @@ fun SearchColumn( val bestMatch by viewModel.bestMatch + val query by viewModel.searchQuery val isSearchEmpty by viewModel.isSearchEmpty val missingCalendarPermission by viewModel.missingCalendarPermission.collectAsState(false) @@ -111,13 +113,14 @@ fun SearchColumn( val expandedCategory: SearchCategory? by viewModel.expandedCategory var selectedAppProfileIndex: Int by remember(isSearchEmpty) { mutableIntStateOf(0) } - var selectedContactIndex: Int by remember(contacts) { mutableIntStateOf(-1) } - var selectedFileIndex: Int by remember(files) { mutableIntStateOf(-1) } - var selectedCalendarIndex: Int by remember(events) { mutableIntStateOf(-1) } - var selectedLocationIndex: Int by remember(locations) { mutableIntStateOf(-1) } - var selectedShortcutIndex: Int by remember(appShortcuts) { mutableIntStateOf(-1) } - var selectedArticleIndex: Int by remember(wikipedia) { mutableIntStateOf(-1) } - var selectedWebsiteIndex: Int by remember(website) { mutableIntStateOf(-1) } + var selectedAppIndex: Int by remember(query) { mutableIntStateOf(-1) } + var selectedContactIndex: Int by remember(query) { mutableIntStateOf(-1) } + var selectedFileIndex: Int by remember(query) { mutableIntStateOf(-1) } + var selectedCalendarIndex: Int by remember(query) { mutableIntStateOf(-1) } + var selectedLocationIndex: Int by remember(query) { mutableIntStateOf(-1) } + var selectedShortcutIndex: Int by remember(query) { mutableIntStateOf(-1) } + var selectedArticleIndex: Int by remember(query) { mutableIntStateOf(-1) } + var selectedWebsiteIndex: Int by remember(query) { mutableIntStateOf(-1) } val showFilters by viewModel.showFilters @@ -193,6 +196,9 @@ fun SearchColumn( columns = columns, reverse = reverse, showProfileLockControls = hasProfilesPermission, + showList = showList, + selectedIndex = selectedAppIndex, + onSelect = { selectedAppIndex = it }, ) } else { AppResults( @@ -202,7 +208,10 @@ fun SearchColumn( selectedAppProfileIndex = it }, columns = columns, - reverse = reverse + reverse = reverse, + showList = showList, + selectedIndex = selectedAppIndex, + onSelect = { selectedAppIndex = it }, ) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt index f90931b4..c49fb0f4 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt @@ -4,6 +4,7 @@ import android.app.PendingIntent import android.content.Intent import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween @@ -61,7 +62,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp -import androidx.compose.ui.unit.roundToIntRect import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import de.mm20.launcher2.crashreporter.CrashReporter @@ -85,11 +85,15 @@ import kotlinx.coroutines.launch fun AppItem( modifier: Modifier = Modifier, app: Application, + showDetails: Boolean, onBack: () -> Unit ) { val viewModel: SearchableItemVM = listItemViewModel(key = "search-${app.key}") val iconSize = LocalGridSettings.current.iconSize.dp.toPixels() + val badge by viewModel.badge.collectAsStateWithLifecycle(null) + val icon by viewModel.icon.collectAsStateWithLifecycle() + LaunchedEffect(app) { viewModel.init(app, iconSize.toInt()) } @@ -97,386 +101,440 @@ fun AppItem( val context = LocalContext.current val scope = rememberCoroutineScope() - Column( - modifier = modifier.verticalScroll(rememberScrollState()) - ) { - Row { - Column( - modifier = Modifier - .weight(1f) - .padding(16.dp) - ) { - Text( - text = app.labelOverride ?: app.label, - style = MaterialTheme.typography.titleMedium - ) - - if (!app.isPrivate) { - - val tags by viewModel.tags.collectAsState(emptyList()) - if (tags.isNotEmpty()) { - Text( - modifier = Modifier.padding(top = 1.dp, bottom = 4.dp), - text = tags.joinToString(separator = " #", prefix = "#"), - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.labelSmall - ) - } - - - app.versionName?.let { - Text( - text = stringResource(R.string.app_info_version, it), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Text( - text = app.componentName.packageName, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 1.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - Text( - stringResource(R.string.profile_private_profile_state_locked), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 8.dp), - color = MaterialTheme.colorScheme.secondary, - ) - } - - } - val badge by viewModel.badge.collectAsStateWithLifecycle(null) - val icon by viewModel.icon.collectAsStateWithLifecycle() - ShapedLauncherIcon( - size = 48.dp, - modifier = Modifier - .padding(16.dp), - badge = { badge }, - icon = { icon }, - ) - } - val notifications by viewModel.notifications.collectAsState(emptyList()) - - AnimatedVisibility(notifications.isNotEmpty()) { - var showAllNotifications by remember { mutableStateOf(false) } - AnimatedContent( - showAllNotifications || notifications.size == 1, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp) - .border( - 1.dp, - MaterialTheme.colorScheme.outlineVariant, - MaterialTheme.shapes.small - ) - .clip(MaterialTheme.shapes.small) - ) { showAll -> - if (showAll) { - Column( - modifier = Modifier.animateContentSize() - ) { - for ((i, not) in notifications.withIndex()) { - val icon = - remember(not.smallIcon) { not.smallIcon?.loadDrawable(context) } - - if (not.title == null && not.text == null) continue - - if (i > 0) { - HorizontalDivider() - } - Row( - verticalAlignment = Alignment.CenterVertically, + SharedTransitionLayout(modifier = modifier) { + AnimatedContent(showDetails) { showDetails -> + if (showDetails) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Row { + Column( + modifier = Modifier + .weight(1f) + .padding(16.dp) + ) { + Text( + text = app.labelOverride ?: app.label, + style = MaterialTheme.typography.titleMedium, modifier = Modifier - .clickable { - try { - not.contentIntent?.sendWithBackgroundPermission(context) - } catch (e: PendingIntent.CanceledException) { - CrashReporter.logException(e) - } - } - .padding(vertical = 4.dp) - ) { - Box( - modifier = Modifier - .padding(horizontal = 12.dp) - .clip(CircleShape) - .background(Color(not.color)) - .size(32.dp) - .padding(8.dp), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - modifier = Modifier.fillMaxSize(), - model = icon, - contentDescription = null + .sharedBounds( + rememberSharedContentState("label"), + this@AnimatedContent, + ), + ) + + if (!app.isPrivate) { + + val tags by viewModel.tags.collectAsState(emptyList()) + if (tags.isNotEmpty()) { + Text( + modifier = Modifier.padding(top = 1.dp, bottom = 4.dp), + text = tags.joinToString(separator = " #", prefix = "#"), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.labelSmall ) } - Column( - modifier = Modifier.weight(1f) - ) { - if (not.title != null) { - Text( - not.title!!, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - if (not.text != null) { - Text( - not.text!!, - modifier = Modifier.padding(top = 2.dp), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + + app.versionName?.let { + Text( + text = stringResource(R.string.app_info_version, it), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = app.componentName.packageName, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 1.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + stringResource(R.string.profile_private_profile_state_locked), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.secondary, + ) + } + + } + ShapedLauncherIcon( + size = 48.dp, + modifier = Modifier + .padding(16.dp), + badge = { badge }, + icon = { icon }, + ) + } + val notifications by viewModel.notifications.collectAsState(emptyList()) + + AnimatedVisibility(notifications.isNotEmpty()) { + var showAllNotifications by remember { mutableStateOf(false) } + AnimatedContent( + showAllNotifications || notifications.size == 1, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + MaterialTheme.shapes.small + ) + .clip(MaterialTheme.shapes.small) + ) { showAll -> + if (showAll) { + Column( + modifier = Modifier.animateContentSize() + ) { + for ((i, not) in notifications.withIndex()) { + val icon = + remember(not.smallIcon) { + not.smallIcon?.loadDrawable( + context + ) + } + + if (not.title == null && not.text == null) continue + + if (i > 0) { + HorizontalDivider() + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + try { + not.contentIntent?.sendWithBackgroundPermission( + context + ) + } catch (e: PendingIntent.CanceledException) { + CrashReporter.logException(e) + } + } + .padding(vertical = 4.dp) + ) { + Box( + modifier = Modifier + .padding(horizontal = 12.dp) + .clip(CircleShape) + .background(Color(not.color)) + .size(32.dp) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = icon, + contentDescription = null + ) + } + Column( + modifier = Modifier.weight(1f) + ) { + if (not.title != null) { + Text( + not.title!!, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (not.text != null) { + Text( + not.text!!, + modifier = Modifier.padding(top = 2.dp), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + if (not.isClearable) { + IconButton( + onClick = { + viewModel.clearNotification(not) + } + ) { + Icon(Icons.Rounded.Clear, null) + } + } + } } } - if (not.isClearable) { + } else { + Row( + modifier = Modifier + .clickable { + showAllNotifications = true + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Rounded.Notifications, + null, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Text( + pluralStringResource( + R.plurals.app_info_notifications, + notifications.size, + notifications.size + ), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.AutoMirrored.Rounded.NavigateNext, + null, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + } + } + } + + val shortcuts by viewModel.children.collectAsState(emptyList()) + if (shortcuts.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + MaterialTheme.shapes.small + ) + .clip(MaterialTheme.shapes.small) + ) { + for ((i, shortcut) in shortcuts.withIndex()) { + val isPinned by remember(shortcut) { + viewModel.isChildPinned( + shortcut + ) + }.collectAsState( + false + ) + + val iconSizePx = 32.dp.toPixels() + + val icon by + remember { + viewModel.getChildIcon( + shortcut, + iconSizePx.toInt() + ) + }.collectAsState(null) + if (i > 0) { + HorizontalDivider() + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + viewModel.launchChild(context, shortcut) + } + .padding(vertical = 4.dp) + ) { + ShapedLauncherIcon( + size = 32.dp, + icon = { icon }, + shape = CircleShape, + modifier = Modifier + .padding(horizontal = 12.dp) + .size(32.dp), + ) + + Text( + shortcut.labelOverride ?: shortcut.label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) IconButton( onClick = { - viewModel.clearNotification(not) + if (isPinned) { + viewModel.unpinChild(shortcut) + } else { + viewModel.pinChild(shortcut) + } } ) { - Icon(Icons.Rounded.Clear, null) + Icon( + if (isPinned) Icons.Rounded.Star else Icons.Rounded.StarOutline, + stringResource(if (isPinned) R.string.menu_favorites_unpin else R.string.menu_favorites_pin), + ) } } + } } } - } else { - Row( - modifier = Modifier - .clickable { - showAllNotifications = true - } - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Rounded.Notifications, - null, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Text( - pluralStringResource( - R.plurals.app_info_notifications, - notifications.size, - notifications.size - ), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), - ) - Icon( - Icons.AutoMirrored.Rounded.NavigateNext, - null, - modifier = Modifier.padding(horizontal = 12.dp) - ) - } - } - } - } - val shortcuts by viewModel.children.collectAsState(emptyList()) - if (shortcuts.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp) - .border( - 1.dp, - MaterialTheme.colorScheme.outlineVariant, - MaterialTheme.shapes.small - ) - .clip(MaterialTheme.shapes.small) - ) { - for ((i, shortcut) in shortcuts.withIndex()) { - val isPinned by remember(shortcut) { viewModel.isChildPinned(shortcut) }.collectAsState( - false + val toolbarActions = mutableListOf() + + if (LocalFavoritesEnabled.current) { + val isPinned by viewModel.isPinned.collectAsState(false) + val favAction = if (isPinned) { + DefaultToolbarAction( + label = stringResource(R.string.menu_favorites_unpin), + icon = Icons.Rounded.Star, + action = { + viewModel.unpin() + } + ) + } else { + DefaultToolbarAction( + label = stringResource(R.string.menu_favorites_pin), + icon = Icons.Rounded.StarOutline, + action = { + viewModel.pin() + }) + } + toolbarActions.add(favAction) + } + + if (!app.isPrivate) { + toolbarActions.add( + DefaultToolbarAction( + label = stringResource(R.string.menu_app_info), + icon = Icons.Rounded.Info + ) { + app.openAppDetails(context) + }) + } + + toolbarActions.add( + DefaultToolbarAction( + label = stringResource(R.string.menu_launch), + icon = Icons.AutoMirrored.Rounded.OpenInNew, + action = { + viewModel.launch(context) + } + ) ) - val iconSizePx = 32.dp.toPixels() - - val icon by - remember { - viewModel.getChildIcon( - shortcut, - iconSizePx.toInt() - ) - }.collectAsState(null) - if (i > 0) { - HorizontalDivider() + val sheetManager = LocalBottomSheetManager.current + if (!app.isPrivate) { + toolbarActions.add( + DefaultToolbarAction( + label = stringResource(R.string.menu_customize), + icon = Icons.Rounded.Tune, + action = { sheetManager.showCustomizeSearchableModal(app) } + )) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { - viewModel.launchChild(context, shortcut) - } - .padding(vertical = 4.dp) - ) { - ShapedLauncherIcon( - size = 32.dp, - icon = { icon }, - shape = CircleShape, - modifier = Modifier - .padding(horizontal = 12.dp) - .size(32.dp), - ) - Text( - shortcut.labelOverride ?: shortcut.label, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - IconButton( - onClick = { - if (isPinned) { - viewModel.unpinChild(shortcut) - } else { - viewModel.pinChild(shortcut) + if (!app.isPrivate) { + val storeDetails = remember(app) { app.getStoreDetails(context) } + val shareAction = if (storeDetails == null) { + DefaultToolbarAction( + label = stringResource(R.string.menu_share), + icon = Icons.Rounded.Share + ) { + scope.launch { + app.shareApkFile(context) } } - ) { - Icon( - if (isPinned) Icons.Rounded.Star else Icons.Rounded.StarOutline, - stringResource(if (isPinned) R.string.menu_favorites_unpin else R.string.menu_favorites_pin), + } else { + SubmenuToolbarAction( + label = stringResource(R.string.menu_share), + icon = Icons.Rounded.Share, + children = listOf( + DefaultToolbarAction( + label = stringResource( + R.string.menu_share_store_link, + storeDetails.label + ), + icon = Icons.Rounded.Link, + action = { + val shareIntent = Intent(Intent.ACTION_SEND) + shareIntent.putExtra( + Intent.EXTRA_TEXT, + storeDetails.url + ) + shareIntent.type = "text/plain" + context.startActivity( + Intent.createChooser( + shareIntent, + null + ) + ) + } + ), + DefaultToolbarAction( + label = stringResource(R.string.menu_share_apk_file), + icon = Icons.Rounded.Android + ) { + scope.launch { + app.shareApkFile(context) + } + } + ) ) } + toolbarActions.add(shareAction) } - } - } - } - - val toolbarActions = mutableListOf() - - if (LocalFavoritesEnabled.current) { - val isPinned by viewModel.isPinned.collectAsState(false) - val favAction = if (isPinned) { - DefaultToolbarAction( - label = stringResource(R.string.menu_favorites_unpin), - icon = Icons.Rounded.Star, - action = { - viewModel.unpin() + if (app.canUninstall) { + toolbarActions.add( + DefaultToolbarAction( + label = stringResource(R.string.menu_uninstall), + icon = Icons.Rounded.Delete, + ) { + app.uninstall(context) + onBack() + } + ) } - ) - } else { - DefaultToolbarAction( - label = stringResource(R.string.menu_favorites_pin), - icon = Icons.Rounded.StarOutline, - action = { - viewModel.pin() - }) - } - toolbarActions.add(favAction) - } - if (!app.isPrivate) { - toolbarActions.add( - DefaultToolbarAction( - label = stringResource(R.string.menu_app_info), - icon = Icons.Rounded.Info - ) { - app.openAppDetails(context) - }) - } - - toolbarActions.add( - DefaultToolbarAction( - label = stringResource(R.string.menu_launch), - icon = Icons.AutoMirrored.Rounded.OpenInNew, - action = { - viewModel.launch(context) - } - ) - ) - - val sheetManager = LocalBottomSheetManager.current - if (!app.isPrivate) { - toolbarActions.add(DefaultToolbarAction( - label = stringResource(R.string.menu_customize), - icon = Icons.Rounded.Tune, - action = { sheetManager.showCustomizeSearchableModal(app) } - )) - } - - if (!app.isPrivate) { - val storeDetails = remember(app) { app.getStoreDetails(context) } - val shareAction = if (storeDetails == null) { - DefaultToolbarAction( - label = stringResource(R.string.menu_share), - icon = Icons.Rounded.Share - ) { - scope.launch { - app.shareApkFile(context) - } - } - } else { - SubmenuToolbarAction( - label = stringResource(R.string.menu_share), - icon = Icons.Rounded.Share, - children = listOf( - DefaultToolbarAction( - label = stringResource( - R.string.menu_share_store_link, - storeDetails.label - ), - icon = Icons.Rounded.Link, - action = { - val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.putExtra(Intent.EXTRA_TEXT, storeDetails.url) - shareIntent.type = "text/plain" - context.startActivity(Intent.createChooser(shareIntent, null)) + Toolbar( + leftActions = listOf( + DefaultToolbarAction( + label = stringResource(id = R.string.menu_back), + icon = Icons.AutoMirrored.Rounded.ArrowBack + ) { + onBack() } ), - DefaultToolbarAction( - label = stringResource(R.string.menu_share_apk_file), - icon = Icons.Rounded.Android - ) { - scope.launch { - app.shareApkFile(context) - } - } + rightActions = toolbarActions ) - ) + } + } else { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (LocalGridSettings.current.showListIcons) { + ShapedLauncherIcon( + size = LocalGridSettings.current.iconSize.dp, + modifier = Modifier + .padding(end = 16.dp), + badge = { badge }, + icon = { icon }, + ) + } + Text( + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = app.labelOverride ?: app.label, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .sharedBounds( + rememberSharedContentState("label"), + this@AnimatedContent, + ), + ) + } } - toolbarActions.add(shareAction) } - - if (app.canUninstall) { - toolbarActions.add( - DefaultToolbarAction( - label = stringResource(R.string.menu_uninstall), - icon = Icons.Rounded.Delete, - ) { - app.uninstall(context) - onBack() - } - ) - } - - Toolbar( - leftActions = listOf( - DefaultToolbarAction( - label = stringResource(id = R.string.menu_back), - icon = Icons.AutoMirrored.Rounded.ArrowBack - ) { - onBack() - } - ), - rightActions = toolbarActions - ) } } @@ -507,6 +565,7 @@ fun AppItemGridPopup( y = lerp(-16.dp, 0.dp, animationProgress) ), app = app, + showDetails = true, onBack = onDismiss ) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt index 559f69c4..30d564b5 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt @@ -4,7 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -22,9 +22,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LeadingIconTab import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -36,6 +36,8 @@ import de.mm20.launcher2.search.Application import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.launcher.search.common.grid.GridItem import de.mm20.launcher2.ui.launcher.search.common.grid.GridResults +import de.mm20.launcher2.ui.launcher.search.common.list.ListItem +import de.mm20.launcher2.ui.launcher.search.common.list.ListResults import de.mm20.launcher2.ui.layout.BottomReversed import de.mm20.launcher2.ui.locals.LocalGridSettings @@ -47,158 +49,192 @@ fun LazyListScope.AppResults( isProfileLocked: Boolean = false, onProfileLockChange: ((Profile, Boolean) -> Unit)? = null, apps: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, highlightedItem: Application? = null, columns: Int, reverse: Boolean, + showList: Boolean, ) { - - GridResults( - key = "apps", - items = apps.filter { it.user == profiles[selectedProfileIndex].userHandle }, - before = if (profiles.size > 1) { - { - Column( - verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top, + val before = if (profiles.size > 1) { + @Composable { + Column( + verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top, + ) { + PrimaryScrollableTabRow( + selectedTabIndex = selectedProfileIndex, + containerColor = Color.Transparent, + edgePadding = 16.dp, + divider = {} ) { - PrimaryScrollableTabRow( - selectedTabIndex = selectedProfileIndex, - containerColor = Color.Transparent, - edgePadding = 16.dp, - divider = {} - ) { - for ((i, profile) in profiles.withIndex()) { - LeadingIconTab( - selected = selectedProfileIndex == profiles.indexOf(profile), - text = { - Text( - when (profile.type) { - Profile.Type.Personal -> stringResource(R.string.apps_profile_main) - Profile.Type.Work -> stringResource(R.string.apps_profile_work) - Profile.Type.Private -> stringResource(R.string.apps_profile_private) - } - ) - }, - icon = { + for ((i, profile) in profiles.withIndex()) { + LeadingIconTab( + selected = selectedProfileIndex == profiles.indexOf(profile), + text = { + Text( when (profile.type) { - Profile.Type.Personal -> Icon( - Icons.Rounded.Person, - contentDescription = null - ) - - Profile.Type.Work -> Icon( - Icons.Rounded.Work, - contentDescription = null - ) - - Profile.Type.Private -> Icon( - Icons.Rounded.PrivateSpace, - contentDescription = null - ) + Profile.Type.Personal -> stringResource(R.string.apps_profile_main) + Profile.Type.Work -> stringResource(R.string.apps_profile_work) + Profile.Type.Private -> stringResource(R.string.apps_profile_private) } - }, - onClick = { - onProfileSelected(i) + ) + }, + icon = { + when (profile.type) { + Profile.Type.Personal -> Icon( + Icons.Rounded.Person, + contentDescription = null + ) + + Profile.Type.Work -> Icon( + Icons.Rounded.Work, + contentDescription = null + ) + + Profile.Type.Private -> Icon( + Icons.Rounded.PrivateSpace, + contentDescription = null + ) } - ) - } + }, + onClick = { + onProfileSelected(i) + } + ) } - HorizontalDivider() + } - val profileType = profiles[selectedProfileIndex].type - if (profileType != Profile.Type.Personal) { - if (isProfileLocked) { - Column( - modifier = Modifier - .padding(12.dp) - .fillMaxWidth() - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.small) - .padding(vertical = 64.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - if (profileType == Profile.Type.Work) Icons.Rounded.WorkOff else Icons.Rounded.Lock, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.secondary, + if (!showList || isProfileLocked) { + HorizontalDivider() + } + + val profileType = profiles[selectedProfileIndex].type + if (profileType != Profile.Type.Personal) { + if (isProfileLocked) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + MaterialTheme.shapes.small ) - Text( - stringResource( - if (profileType == Profile.Type.Work) R.string.profile_work_profile_state_locked - else R.string.profile_private_profile_state_locked - ), - modifier = Modifier.padding(top = 8.dp), - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.titleSmall, + .background( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.shapes.small ) - if (showProfileLockControls) { - Button( - modifier = Modifier.padding(top = 32.dp), - onClick = { - onProfileLockChange?.invoke( - profiles[selectedProfileIndex], - false - ) - }, - contentPadding = ButtonDefaults.TextButtonWithIconContentPadding, - ) { - Icon( - if (profileType == Profile.Type.Work) Icons.Rounded.Work else Icons.Rounded.LockOpen, - contentDescription = null, - modifier = Modifier - .padding(end = ButtonDefaults.IconSpacing) - .size(ButtonDefaults.IconSize) + .padding(vertical = 64.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + if (profileType == Profile.Type.Work) Icons.Rounded.WorkOff else Icons.Rounded.Lock, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + Text( + stringResource( + if (profileType == Profile.Type.Work) R.string.profile_work_profile_state_locked + else R.string.profile_private_profile_state_locked + ), + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.titleSmall, + ) + if (showProfileLockControls) { + Button( + modifier = Modifier.padding(top = 32.dp), + onClick = { + onProfileLockChange?.invoke( + profiles[selectedProfileIndex], + false ) - Text( - stringResource( - if (profileType == Profile.Type.Work) R.string.profile_work_profile_action_unlock - else R.string.profile_private_profile_action_unlock - ) + }, + contentPadding = ButtonDefaults.TextButtonWithIconContentPadding, + ) { + Icon( + if (profileType == Profile.Type.Work) Icons.Rounded.Work else Icons.Rounded.LockOpen, + contentDescription = null, + modifier = Modifier + .padding(end = ButtonDefaults.IconSpacing) + .size(ButtonDefaults.IconSize) + ) + Text( + stringResource( + if (profileType == Profile.Type.Work) R.string.profile_work_profile_action_unlock + else R.string.profile_private_profile_action_unlock ) - } + ) } } - } else if (showProfileLockControls) { - FilledTonalButton( + } + } else if (showProfileLockControls) { + FilledTonalButton( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth(), + onClick = { + onProfileLockChange?.invoke( + profiles[selectedProfileIndex], + true + ) + }, + contentPadding = ButtonDefaults.TextButtonWithIconContentPadding, + ) { + Icon( + if (profileType == Profile.Type.Work) Icons.Rounded.WorkOff else Icons.Rounded.Lock, + contentDescription = null, modifier = Modifier - .padding(12.dp) - .fillMaxWidth(), - onClick = { - onProfileLockChange?.invoke( - profiles[selectedProfileIndex], - true - ) - }, - contentPadding = ButtonDefaults.TextButtonWithIconContentPadding, - ) { - Icon( - if (profileType == Profile.Type.Work) Icons.Rounded.WorkOff else Icons.Rounded.Lock, - contentDescription = null, - modifier = Modifier - .padding(end = ButtonDefaults.IconSpacing) - .size(ButtonDefaults.IconSize) + .padding(end = ButtonDefaults.IconSpacing) + .size(ButtonDefaults.IconSize) + ) + Text( + stringResource( + if (profileType == Profile.Type.Work) R.string.profile_work_profile_action_lock + else R.string.profile_private_profile_action_lock ) - Text( - stringResource( - if (profileType == Profile.Type.Work) R.string.profile_work_profile_action_lock - else R.string.profile_private_profile_action_lock - ) - ) - } + ) } } } } - } else null, - itemContent = { - GridItem( - item = it, - showLabels = LocalGridSettings.current.showLabels, - highlight = it.key == highlightedItem?.key - ) - }, - reverse = reverse, - columns = columns, - ) + } + } else null + if (showList) { + ListResults( + key = "apps", + items = apps.filter { it.user == profiles[selectedProfileIndex].userHandle }, + before = before?.let { { it() } }, + selectedIndex = selectedIndex, + itemContent = { app, showDetails, index -> + ListItem( + modifier = Modifier + .fillMaxWidth(), + item = app, + showDetails = showDetails, + onShowDetails = { onSelect(if(it) index else -1) }, + highlight = highlightedItem?.key == app.key + ) + }, + reverse = reverse, + ) + } else { + GridResults( + key = "apps", + items = apps.filter { it.user == profiles[selectedProfileIndex].userHandle }, + before = before, + itemContent = { + GridItem( + item = it, + showLabels = LocalGridSettings.current.showLabels, + highlight = it.key == highlightedItem?.key + ) + }, + reverse = reverse, + columns = columns, + ) + } + } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt index e3c07fa9..fbb2bef0 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt @@ -16,6 +16,7 @@ import de.mm20.launcher2.notifications.Notification import de.mm20.launcher2.notifications.NotificationRepository import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.search.ContactSearchSettings import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.Application @@ -53,6 +54,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { private val appShortcutRepository: AppShortcutRepository by inject() private val permissionsManager: PermissionsManager by inject() private val locationSearchSettings: LocationSearchSettings by inject() + private val contactSearchSettings: ContactSearchSettings by inject() val isUpToDate = MutableStateFlow(true) @@ -138,7 +140,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { } val bundle = options.toBundle() if (searchable.launch(context, bundle)) { - favoritesService.reportLaunch(searchable) + reportUsage(searchable) return true } else if (searchable is Application || searchable is AppShortcut) { favoritesService.reset(searchable) @@ -168,7 +170,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { fun launchChild(context: Context, child: SavableSearchable) { if (child.launch(context, null)) { - favoritesService.reportLaunch(child) + reportUsage(child) } } @@ -246,4 +248,11 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { val mapTileServerUrl = locationSearchSettings.tileServer .map { it ?: LocationSearchSettings.DefaultTileServerUrl } .stateIn(viewModelScope, SharingStarted.Lazily, "") + + val callOnTap = contactSearchSettings.callOnTap + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + fun reportUsage(searchable: SavableSearchable) { + favoritesService.reportLaunch(searchable) + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt index a7187108..60e7754b 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt @@ -1,6 +1,5 @@ package de.mm20.launcher2.ui.launcher.search.common.grid -import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.MutableTransitionState @@ -13,6 +12,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -110,6 +110,7 @@ fun GridItem( Column( modifier = modifier + .padding(4.dp) .combinedClickable( onClick = { if (!launchOnPress || !viewModel.launch(context, bounds)) { @@ -170,7 +171,9 @@ fun GridItem( modifier = Modifier .padding(4.dp) .onGloballyPositioned { - bounds = it.boundsInWindow().roundToIntRect() + bounds = it + .boundsInWindow() + .roundToIntRect() } then if (highlight) Modifier.background( MaterialTheme.colorScheme.surface, @@ -195,10 +198,10 @@ fun GridItem( color = MaterialTheme.colorScheme.onBackground, ) } + } - if (showPopup) { - ItemPopup(origin = bounds, searchable = item, onDismissRequest = { showPopup = false }) - } + if (showPopup) { + ItemPopup(origin = bounds, searchable = item, onDismissRequest = { showPopup = false }) } } @@ -398,7 +401,7 @@ private fun Modifier.placeOverlay( constraints.maxHeight - placeable.height, ), animationProgress.pow(2) - ).toInt() + ) ) } } @@ -410,4 +413,4 @@ private fun lerp(start: Float, stop: Float, fraction: Float): Float { private fun lerp(start: Int, stop: Int, fraction: Float): Int { return start + (fraction * (stop - start)).toInt() -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt index 72202b43..acd159aa 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt @@ -83,8 +83,8 @@ fun LazyListScope.GridResults( .padding( top = if (it == 0) 8.dp else 0.dp, bottom = if (it == rows - 1) 8.dp else 0.dp, - start = 4.dp, - end = 4.dp, + start = if (columns == 1) 0.dp else 4.dp, + end = if (columns == 1) 0.dp else 4.dp, ) ) { Row { @@ -94,7 +94,6 @@ fun LazyListScope.GridResults( Box( modifier = Modifier .weight(1f) - .padding(4.dp) ) { itemContent(item) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt index c212c5db..844fa9eb 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -22,6 +23,7 @@ import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.roundToIntRect import de.mm20.launcher2.search.AppShortcut +import de.mm20.launcher2.search.Application import de.mm20.launcher2.search.Article import de.mm20.launcher2.search.CalendarEvent import de.mm20.launcher2.search.Contact @@ -30,6 +32,7 @@ import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.Website import de.mm20.launcher2.ui.ktx.toPixels +import de.mm20.launcher2.ui.launcher.search.apps.AppItem import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItem import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM import de.mm20.launcher2.ui.launcher.search.contacts.ContactItem @@ -80,6 +83,25 @@ fun ListItem( LocalContentColor provides MaterialTheme.colorScheme.onSurface ) { when (item) { + is Application -> { + AppItem( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 9999.dp) // we have infinite space, but there is an inner scroll that needs a constraint + .combinedClickable( + enabled = !showDetails, + onClick = { + if (!viewModel.launch(context, bounds)) { + onShowDetails(true) + } + }, + onLongClick = { onShowDetails(true) } + ), + app = item, + showDetails = showDetails, + onBack = { onShowDetails(false) } + ) + } is Contact -> { ContactItem( modifier = Modifier diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt index 74194f5d..efacd775 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt @@ -83,6 +83,8 @@ import de.mm20.launcher2.ui.modifier.scale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import androidx.core.net.toUri +import de.mm20.launcher2.ktx.checkPermission @Composable fun ContactItem( @@ -101,6 +103,7 @@ fun ContactItem( } val icon by viewModel.icon.collectAsStateWithLifecycle() + val callOnTap by viewModel.callOnTap.collectAsStateWithLifecycle(false) val badge by viewModel.badge.collectAsState(null) SharedTransitionLayout { @@ -163,6 +166,7 @@ fun ContactItem( .fillMaxWidth(), secondaryAction = { IconButton(onClick = { + viewModel.reportUsage(contact) context.tryStartActivity( Intent(Intent.ACTION_SENDTO).apply { data = Uri.parse("smsto:${it.number}") @@ -180,10 +184,14 @@ fun ContactItem( expandedSection = if (it) 0 else -1 }, onContact = { + viewModel.reportUsage(contact) context.tryStartActivity( - Intent(Intent.ACTION_DIAL).apply { - data = Uri.parse("tel:${it.number}") - } + Intent( + if (callOnTap) + Intent.ACTION_CALL + else + Intent.ACTION_DIAL + ).setData("tel:${it.number}".toUri()) ) }, copyText = { it.number }, @@ -208,6 +216,7 @@ fun ContactItem( expandedSection = if (it) 1 else -1 }, onContact = { + viewModel.reportUsage(contact) context.tryStartActivity( Intent(Intent.ACTION_SENDTO).apply { data = Uri.parse("mailto:${it.address}") @@ -231,6 +240,7 @@ fun ContactItem( secondaryAction = if (canNavigate) { { IconButton(onClick = { + viewModel.reportUsage(contact) context.tryStartActivity( Intent(Intent.ACTION_VIEW).apply { data = @@ -254,6 +264,7 @@ fun ContactItem( expandedSection = if (it) 2 else -1 }, onContact = { + viewModel.reportUsage(contact) context.tryStartActivity( Intent(Intent.ACTION_VIEW).apply { data = Uri.parse("geo:0,0?q=${it.address}") @@ -295,11 +306,22 @@ fun ContactItem( app.key } } + val itemsWithPermission = remember(app) { + app.value.filter { + // exclude activities we have no permission for + val resolvedActivityInfo = context.packageManager.resolveActivity( + Intent(Intent.ACTION_VIEW).setDataAndType(it.uri, it.mimeType), + 0 + )?.activityInfo ?: return@filter false + + resolvedActivityInfo.permission == null || context.checkPermission(resolvedActivityInfo.permission) + } + } ContactInfo( icon = Icons.AutoMirrored.Rounded.OpenInNew, customIcon = appIcon, label = label, - items = app.value, + items = itemsWithPermission, itemLabel = { it.label }, expanded = expandedSection == 3 + i, modifier = Modifier @@ -309,6 +331,7 @@ fun ContactItem( expandedSection = if (it) 3 + i else -1 }, onContact = { + viewModel.reportUsage(contact) context.tryStartActivity( Intent(Intent.ACTION_VIEW).apply { setDataAndType( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt index f80508a5..f7b07a00 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt @@ -1,5 +1,6 @@ package de.mm20.launcher2.ui.launcher.search.location +import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.animation.AnimatedContent @@ -14,29 +15,36 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkOut import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.MarqueeSpacing import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.NavigateNext @@ -60,13 +68,19 @@ import androidx.compose.material.icons.rounded.Tram import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard 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.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -77,22 +91,25 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.times -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.ui.util.fastFilterNotNull +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import blend.Blend.harmonize import coil.compose.AsyncImage @@ -103,9 +120,11 @@ import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.isOpen import de.mm20.launcher2.search.location.Attribution import de.mm20.launcher2.search.location.Departure +import de.mm20.launcher2.search.location.LineNameComparator import de.mm20.launcher2.search.location.LineType import de.mm20.launcher2.search.location.OpeningHours import de.mm20.launcher2.search.location.OpeningSchedule +import de.mm20.launcher2.search.location.isNotEmpty import de.mm20.launcher2.ui.base.LocalTime import de.mm20.launcher2.ui.component.DefaultToolbarAction import de.mm20.launcher2.ui.component.MarqueeText @@ -113,24 +132,27 @@ import de.mm20.launcher2.ui.component.RatingBar import de.mm20.launcher2.ui.component.ShapedLauncherIcon import de.mm20.launcher2.ui.component.Toolbar import de.mm20.launcher2.ui.component.ToolbarAction +import de.mm20.launcher2.ui.ktx.atTone import de.mm20.launcher2.ui.ktx.blendIntoViewScale import de.mm20.launcher2.ui.ktx.metersToLocalizedString +import de.mm20.launcher2.ui.ktx.toComposeColor import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM import de.mm20.launcher2.ui.launcher.search.listItemViewModel import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager +import de.mm20.launcher2.ui.locals.LocalDarkTheme import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled import de.mm20.launcher2.ui.locals.LocalGridSettings -import de.mm20.launcher2.ui.locals.LocalSnackbarHostState import de.mm20.launcher2.ui.modifier.scale +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.emptyFlow import java.time.Duration import java.time.LocalDateTime import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -import java.time.format.TextStyle import java.util.Locale import kotlin.math.pow +import java.time.format.TextStyle as JavaTextStyle @Composable fun LocationItem( @@ -168,7 +190,6 @@ fun LocationItem( val imperialUnits by viewModel.imperialUnits.collectAsState() val showMap by viewModel.showMap.collectAsState() - val isUpToDate by viewModel.isUpToDate.collectAsState() val distance = userLocation?.distanceTo(location.toAndroidLocation()) @@ -195,7 +216,7 @@ fun LocationItem( modifier = Modifier .weight(1f) .padding(horizontal = 8.dp), - verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + verticalArrangement = Arrangement.Center, ) { Text( text = location.labelOverride ?: location.label, @@ -206,19 +227,17 @@ fun LocationItem( this@AnimatedContent ) ) - val category = location.category - val formattedDistance = distance?.metersToLocalizedString( - context, imperialUnits - ) - if (category != null || formattedDistance != null) { - Text( - when { - category != null && formattedDistance != null -> "$category • $formattedDistance" + val formattedDistance = + distance?.metersToLocalizedString(context, imperialUnits) + val isOpenString = location.openingSchedule?.isOpen() + ?.let { stringResource(if (it) R.string.location_open else R.string.location_closed) } + val sublabel = listOf(location.category, formattedDistance, isOpenString) + .fastFilterNotNull() + .joinToString(" • ") - category != null -> category.toString() - formattedDistance != null -> formattedDistance - else -> "" - }, + if (sublabel.isNotBlank()) { + Text( + sublabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, modifier = Modifier @@ -314,7 +333,7 @@ fun LocationItem( when { category != null && formattedDistance != null -> "$category • $formattedDistance" - category != null -> category.toString() + category != null -> category formattedDistance != null -> formattedDistance else -> "" }, @@ -333,7 +352,7 @@ fun LocationItem( location.userRating!!, modifier = Modifier .padding(top = 6.dp) - .offset(-2.dp) + .offset((-2).dp) ) } @@ -390,7 +409,7 @@ fun LocationItem( context.tryStartActivity( Intent( Intent.ACTION_VIEW, - Uri.parse(attribution.url) + attribution.url!!.toUri() ) ) } @@ -401,253 +420,23 @@ fun LocationItem( val openingSchedule = location.openingSchedule val departures = remember(location.departures) { - location.departures - ?.sortedBy { it.time } + location.departures?.sortedBy { it.time } } if (departures != null) { - val time = LocalTime.current - val nextDeparture = remember(time) { - departures.firstOrNull { - it.time.plus(it.delay ?: Duration.ZERO).isAfter(ZonedDateTime.now()) - } - } - if (nextDeparture != null) { - var showDepartureList by remember(departures) { - mutableStateOf(false) - } - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp, top = 12.dp), - shape = MaterialTheme.shapes.small, - onClick = { showDepartureList = !showDepartureList } - ) { - val listState = rememberLazyListState() - - AnimatedContent(showDepartureList) { showList -> - if (!showList) { - Row( - Modifier - .padding(12.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - nextDeparture.LineIcon(Modifier.padding(end = 8.dp)) - val lastStop = nextDeparture.lastStop - if (lastStop != null) { - MarqueeText( - modifier = Modifier.weight(1f), - text = lastStop, - style = MaterialTheme.typography.labelMedium, - iterations = Int.MAX_VALUE, - repeatDelayMillis = 0, - velocity = 20.dp, - fadeLeft = 5.dp, - fadeRight = 5.dp, - ) - } - - val formattedTime = remember(time) { - val timeLeft = Duration.between( - java.time.LocalTime.now(), - nextDeparture.time + (nextDeparture.delay - ?: Duration.ZERO) - ).toMinutes() - if (timeLeft < 1) "now" else "in $timeLeft min" - } - - Text( - text = formattedTime, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(end = 12.dp) - ) - Icon(Icons.AutoMirrored.Rounded.NavigateNext, null) - } - } else { - val longestLine = remember(departures) { - departures.maxOfOrNull { it.line.length } - } - LazyColumn( - state = listState, - modifier = modifier - .heightIn(max = 192.dp) - .padding(12.dp) - .fillMaxWidth() - .pointerInput(Unit) { detectDragGestures { _, _ -> } }, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - itemsIndexed( - departures, - key = { idx, _ -> idx }) { idx, it -> - it.LazyColumnPart( - lineWidth = longestLine, - Modifier - .fillMaxWidth() - .graphicsLayer { - alpha = - listState.layoutInfo.blendIntoViewScale( - idx - ) - } - ) - } - } - } - } - } - } + Departures( + Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, top = 12.dp), + departures = departures, + ) } - if (openingSchedule is OpeningSchedule.TwentyFourSeven || (openingSchedule is OpeningSchedule.Hours && openingSchedule.openingHours.isNotEmpty())) { - var showOpeningSchedule by remember(openingSchedule) { - mutableStateOf(false) - } - OutlinedCard( + if (openingSchedule?.isNotEmpty() == true) { + OpeningSchedule( modifier = Modifier .fillMaxWidth() .padding(start = 12.dp, end = 12.dp, top = 12.dp), - shape = MaterialTheme.shapes.small, - onClick = { - if (openingSchedule !is OpeningSchedule.TwentyFourSeven) { - showOpeningSchedule = !showOpeningSchedule - } - } - ) { - AnimatedContent(showOpeningSchedule) { showSchedule -> - if (!showSchedule) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - when (openingSchedule) { - is OpeningSchedule.TwentyFourSeven -> { - Text( - text = stringResource(R.string.location_open_24_7), - style = MaterialTheme.typography.labelMedium, - ) - } - - is OpeningSchedule.Hours -> { - val text = remember(openingSchedule) { - val currentOpeningTime = - openingSchedule.getCurrentOpeningHours() - val timeFormat = - DateTimeFormatter.ofLocalizedTime( - FormatStyle.SHORT - ) - return@remember if (currentOpeningTime != null) { - val isSameDay = - currentOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek - val formattedTime = - timeFormat.format(currentOpeningTime.startTime + currentOpeningTime.duration) - val closingTime = if (isSameDay) { - context.getString( - R.string.location_closes, - formattedTime - ) - } else { - val dow = - currentOpeningTime.dayOfWeek.getDisplayName( - TextStyle.SHORT, - Locale.getDefault() - ) - context.getString( - R.string.location_closes_other_day, - dow, - formattedTime - ) - } - "${context.getString(R.string.location_open)} • $closingTime" - } else { - val nextOpeningTime = - openingSchedule.getNextOpeningHours() - val isSameDay = - nextOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek - val formattedTime = - timeFormat.format(nextOpeningTime.startTime) - val openingTime = if (isSameDay) { - context.getString( - R.string.location_opens, - formattedTime - ) - } else { - val dow = - nextOpeningTime.dayOfWeek.getDisplayName( - TextStyle.SHORT, - Locale.getDefault() - ) - context.getString( - R.string.location_opens_other_day, - dow, - formattedTime - ) - } - "${context.getString(R.string.location_closed)} • $openingTime" - } - } - - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.weight(1f) - ) - - Icon(Icons.AutoMirrored.Rounded.NavigateNext, null) - } - } - } - } else if (openingSchedule is OpeningSchedule.Hours) { - Column( - modifier = Modifier.padding(vertical = 6.dp) - ) { - val groups = remember(openingSchedule) { - openingSchedule.openingHours - .groupBy { it.dayOfWeek }.entries - .sortedBy { it.key } - } - - for (group in groups) { - Row( - modifier = Modifier.padding( - vertical = 2.dp, - horizontal = 12.dp - ), - ) { - Text( - modifier = Modifier.weight(1f), - text = group.key.getDisplayName( - TextStyle.FULL, - Locale.getDefault() - ), - style = MaterialTheme.typography.bodySmall, - ) - val times = remember(group.value) { - val dateFormatter = - DateTimeFormatter.ofLocalizedTime( - FormatStyle.SHORT - ) - group.value.sortedBy { it.startTime } - .joinToString(separator = ", ") { - "${it.startTime.format(dateFormatter)}–${ - it.startTime.plus( - it.duration - ).format(dateFormatter) - }" - } - } - Text( - text = times, - style = MaterialTheme.typography.labelMedium, - ) - } - } - } - } - } - - } + openingSchedule = openingSchedule, + ) } Row( @@ -657,7 +446,7 @@ fun LocationItem( ) { val navigationIntent = Intent( Intent.ACTION_VIEW, - Uri.parse("google.navigation:q=${location.latitude},${location.longitude}") + "google.navigation:q=${location.latitude},${location.longitude}".toUri() ) val canResolveNavigationIntent = remember { null != context.packageManager.resolveActivity(navigationIntent, 0) @@ -675,13 +464,14 @@ fun LocationItem( } ) } - location.phoneNumber?.let { + if (location.phoneNumber != null) { AssistChip( modifier = Modifier.padding(end = 12.dp), onClick = { context.tryStartActivity( Intent( - Intent.ACTION_DIAL, Uri.parse("tel:$it") + Intent.ACTION_DIAL, + "tel:${location.phoneNumber}".toUri() ) ) }, @@ -695,13 +485,13 @@ fun LocationItem( ) } - location.websiteUrl?.let { + if (location.websiteUrl != null) { AssistChip( modifier = Modifier.padding(end = 12.dp), onClick = { context.tryStartActivity( Intent( - Intent.ACTION_VIEW, Uri.parse(it) + Intent.ACTION_VIEW, location.websiteUrl!!.toUri() ) ) }, @@ -748,23 +538,22 @@ fun LocationItem( } val sheetManager = LocalBottomSheetManager.current - val lifecycleOwner = LocalLifecycleOwner.current - val snackbarHostState = LocalSnackbarHostState.current - toolbarActions.add(DefaultToolbarAction( - label = stringResource(R.string.menu_customize), - icon = Icons.Rounded.Tune, - action = { sheetManager.showCustomizeSearchableModal(location) } - )) + toolbarActions.add( + DefaultToolbarAction( + label = stringResource(R.string.menu_customize), + icon = Icons.Rounded.Tune, + action = { sheetManager.showCustomizeSearchableModal(location) } + )) - location.fixMeUrl?.let { + if (location.fixMeUrl != null) { toolbarActions += DefaultToolbarAction( label = stringResource(id = R.string.menu_bugreport), icon = Icons.Rounded.BugReport, ) { context.tryStartActivity( Intent( - Intent.ACTION_VIEW, Uri.parse(location.fixMeUrl) + Intent.ACTION_VIEW, location.fixMeUrl!!.toUri() ) ) } @@ -772,12 +561,13 @@ fun LocationItem( Toolbar( modifier = Modifier.fillMaxWidth(), - leftActions = listOf(DefaultToolbarAction( - label = stringResource(id = R.string.menu_back), - icon = Icons.AutoMirrored.Rounded.ArrowBack - ) { - onBack() - }), + leftActions = listOf( + DefaultToolbarAction( + label = stringResource(id = R.string.menu_back), + icon = Icons.AutoMirrored.Rounded.ArrowBack + ) { + onBack() + }), rightActions = toolbarActions, ) } @@ -787,7 +577,346 @@ fun LocationItem( } @Composable -fun Compass( +private fun Departures( + modifier: Modifier = Modifier, + departures: List, +) { + val context = LocalContext.current + + val nextDeparture = key(LocalTime.current) { + departures.firstOrNull { + it.time.plus(it.delay ?: Duration.ZERO).isAfter(ZonedDateTime.now()) + } + } + var animateFilterChipsOnce by remember { mutableStateOf(true) } + if (nextDeparture != null) { + var showDepartureList by remember { mutableStateOf(false) } + OutlinedCard( + modifier = modifier, + shape = MaterialTheme.shapes.small, + onClick = { showDepartureList = true } + ) { + val listState = rememberLazyListState() + + AnimatedContent(showDepartureList) { showList -> + if (!showList) { + Row( + Modifier + .padding(12.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + LineIcon( + departure = nextDeparture, + Modifier.padding(end = 8.dp) + ) + val lastStop = nextDeparture.lastStop + if (lastStop != null) { + MarqueeText( + modifier = Modifier.weight(1f), + text = lastStop, + style = MaterialTheme.typography.labelMedium, + iterations = Int.MAX_VALUE, + repeatDelayMillis = 0, + velocity = 20.dp, + fadeLeft = 5.dp, + fadeRight = 5.dp, + ) + } + + Text( + text = key(LocalTime.current) { + departureInMinutes(context, nextDeparture) + }, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(end = 12.dp) + ) + Icon(Icons.AutoMirrored.Rounded.NavigateNext, null) + } + } else { + val (lines, groupedDepartures) = remember(departures) { + val dict = departures.groupBy { it.line to it.type } + dict.keys.toList().sortedWith { (line1, type1), (line2, type2) -> + if (type1 != type2) compareValues(type1?.ordinal, type2?.ordinal) + else LineNameComparator.compare(line1, line2) + } to dict + } + + var showMinutes by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { showMinutes = !showMinutes }, + onLongClick = { showDepartureList = false }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + .padding(bottom = 8.dp) + ) { + + Column { + val filterChipListState = + rememberLazyListState() + var selectedLine by remember { + mutableStateOf( + nextDeparture.line to nextDeparture.type + ) + } + LaunchedEffect(Unit) { + val itemIdx = lines.indexOf(selectedLine) + if (itemIdx != -1) { + if (animateFilterChipsOnce) { + delay(500) + filterChipListState.animateScrollToItem( + itemIdx + ) + animateFilterChipsOnce = false + } else + filterChipListState.scrollToItem( + itemIdx + ) + } + } + LazyRow( + state = filterChipListState, + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + itemsIndexed( + lines, + key = { idx, _ -> idx } + ) { idx, it -> + val (lineName, _) = it + val firstDeparture = + groupedDepartures[it]?.first() + if (firstDeparture != null) { + LineFilterChip( + lineName = lineName, + lineColor = firstDeparture.lineColor?.toComposeColor(), + lineType = firstDeparture.type, + selected = selectedLine == it, + onClick = { + selectedLine = it + }, + modifier = Modifier + .padding( + top = 12.dp, + bottom = 12.dp, + start = 4.dp, + end = 4.dp, + ) + .graphicsLayer { + alpha = + filterChipListState.layoutInfo + .blendIntoViewScale( + idx, + 0.5f + ) + } + ) + } + } + } + AnimatedContent( + selectedLine, + transitionSpec = { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + .togetherWith(fadeOut(animationSpec = tween(90))) + } + ) { line -> + + val selectedDepartures = groupedDepartures[line] + + if (selectedDepartures != null) { + Column( + modifier = Modifier.padding(horizontal = 12.dp) + ) { + for (i in 0.. 0) { + HorizontalDivider() + } + val dep = selectedDepartures[i] + DepartureRow( + departure = dep, + lineWidth = remember( + selectedDepartures + ) { + selectedDepartures.maxOfOrNull { it.line.length } + }, + withIcon = false, + minutesInsteadOfTime = showMinutes, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun OpeningSchedule( + modifier: Modifier = Modifier, + openingSchedule: OpeningSchedule, +) { + val context = LocalContext.current + + var showOpeningSchedule by remember(openingSchedule) { mutableStateOf(false) } + OutlinedCard( + modifier = modifier, + shape = MaterialTheme.shapes.small, + onClick = { + if (openingSchedule !is OpeningSchedule.TwentyFourSeven) { + showOpeningSchedule = !showOpeningSchedule + } + } + ) { + AnimatedContent(showOpeningSchedule) { showSchedule -> + if (!showSchedule) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when (openingSchedule) { + is OpeningSchedule.TwentyFourSeven -> { + Text( + text = stringResource(R.string.location_open_24_7), + style = MaterialTheme.typography.labelMedium, + ) + } + + is OpeningSchedule.Hours -> { + val text = remember(openingSchedule) { + val currentOpeningTime = + openingSchedule.getCurrentOpeningHours() + val timeFormat = + DateTimeFormatter.ofLocalizedTime( + FormatStyle.SHORT + ) + return@remember if (currentOpeningTime != null) { + val isSameDay = + currentOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek + val formattedTime = + timeFormat.format(currentOpeningTime.startTime + currentOpeningTime.duration) + val closingTime = if (isSameDay) { + context.getString( + R.string.location_closes, + formattedTime + ) + } else { + val dow = + currentOpeningTime.dayOfWeek.getDisplayName( + JavaTextStyle.SHORT, + Locale.getDefault() + ) + context.getString( + R.string.location_closes_other_day, + dow, + formattedTime + ) + } + "${context.getString(R.string.location_open)} • $closingTime" + } else { + val nextOpeningTime = + openingSchedule.getNextOpeningHours() + val isSameDay = + nextOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek + val formattedTime = + timeFormat.format(nextOpeningTime.startTime) + val openingTime = if (isSameDay) { + context.getString( + R.string.location_opens, + formattedTime + ) + } else { + val dow = + nextOpeningTime.dayOfWeek.getDisplayName( + JavaTextStyle.SHORT, + Locale.getDefault() + ) + context.getString( + R.string.location_opens_other_day, + dow, + formattedTime + ) + } + "${context.getString(R.string.location_closed)} • $openingTime" + } + } + + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.weight(1f) + ) + + Icon(Icons.AutoMirrored.Rounded.NavigateNext, null) + } + } + } + } else if (openingSchedule is OpeningSchedule.Hours) { + Column( + modifier = Modifier.padding(vertical = 6.dp) + ) { + val groups = remember(openingSchedule) { + openingSchedule.openingHours + .groupBy { it.dayOfWeek }.entries + .sortedBy { it.key } + } + + for (group in groups) { + Row( + modifier = Modifier.padding( + vertical = 2.dp, + horizontal = 12.dp + ), + ) { + Text( + modifier = Modifier.weight(1f), + text = group.key.getDisplayName( + JavaTextStyle.FULL, + Locale.getDefault() + ), + style = MaterialTheme.typography.bodySmall, + ) + val times = remember(group.value) { + val dateFormatter = + DateTimeFormatter.ofLocalizedTime( + FormatStyle.SHORT + ) + group.value.sortedBy { it.startTime } + .joinToString(separator = ", ") { + "${it.startTime.format(dateFormatter)}–${ + it.startTime.plus( + it.duration + ).format(dateFormatter) + }" + } + } + Text( + text = times, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } + + } +} + +@Composable +private fun Compass( targetHeading: Float?, modifier: Modifier = Modifier, size: Dp = 48.dp @@ -889,74 +1018,131 @@ private fun OpeningSchedule.Hours.getNextOpeningHours(): OpeningHours { } @Composable -fun Departure.LineIcon( - modifier: Modifier +fun LineMarqueeText( + lineName: String, + lineForeground: Color, + style: TextStyle, + modifier: Modifier = Modifier +) = MarqueeText( + text = lineName, + style = style, + color = lineForeground, + textAlign = TextAlign.Center, + fadeLeft = 2.5.dp, + fadeRight = 2.5.dp, + iterations = Int.MAX_VALUE, + repeatDelayMillis = 0, + spacing = MarqueeSpacing(10.dp), + velocity = 20.dp, + modifier = modifier +) + +@Composable +fun LineTypeIcon( + lineType: LineType?, + tint: Color, + modifier: Modifier = Modifier +) = Icon( + imageVector = when (lineType) { + LineType.Bus -> Icons.Rounded.DirectionsBus + LineType.Tram -> Icons.Rounded.Tram + LineType.Subway -> Icons.Rounded.Subway + LineType.Monorail -> Icons.Rounded.DirectionsTransit + LineType.CommuterTrain -> Icons.Rounded.DirectionsRailway + LineType.Train, LineType.RegionalTrain, LineType.HighSpeedTrain -> Icons.Rounded.Train + LineType.Boat -> Icons.Rounded.DirectionsBoat + LineType.CableCar -> Icons.Rounded.CableCar + LineType.Airplane -> Icons.Rounded.AirplanemodeActive + null -> Icons.Rounded.Commute + }, + contentDescription = lineType?.name, // TODO localize (maybe) with ?.let{ stringResource("departure_line_type_$it") } + modifier = modifier, + tint = tint +) + +@Composable +fun LineFilterChip( + lineName: String, + lineColor: Color?, + lineType: LineType?, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, ) { - val harmonizeArgb = MaterialTheme.colorScheme.primary.toArgb() - var (lineBg, lineFg) = if (lineColor != null) { - val bg = Color( - harmonize(lineColor!!.toArgb(), harmonizeArgb) - ) - val fg = Color( - harmonize( - if (0.5f < bg.luminance()) Color.Black.toArgb() else Color.White.toArgb(), - harmonizeArgb - ) - ) - bg to fg - } else { - MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer - } + val scale = 0.875f - val hasDeparted = ZonedDateTime.now().isAfter(time + (delay ?: Duration.ZERO)) + val dark = LocalDarkTheme.current + val color = + if (lineColor == null) MaterialTheme.colorScheme.primary + else Color(harmonize(lineColor.toArgb(), MaterialTheme.colorScheme.primary.toArgb())) - if (hasDeparted) { - val hsv = FloatArray(3) - android.graphics.Color.colorToHSV(lineBg.toArgb(), hsv) - val (h, s, v) = hsv - lineBg = Color.hsv(h, s / 2f, v, lineBg.alpha) - } + InputChip( + selected = selected, + onClick = onClick, + label = { + Text(lineName, style = MaterialTheme.typography.labelMedium) + }, + avatar = { + Box( + modifier = Modifier + .background(color.atTone(if (dark) 80 else 40)) + .clip(CircleShape) + .requiredSize( + InputChipDefaults.AvatarSize * scale + ) + ) { + LineTypeIcon( + lineType = lineType, + tint = color.atTone(if (dark) 20 else 100), + modifier = Modifier + .fillMaxSize() + .padding(4.dp) + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + selectedLabelColor = color.atTone(if (dark) 90 else 30), + selectedContainerColor = color.atTone(if (dark) 30 else 90), + ), + modifier = modifier.height(FilterChipDefaults.Height * scale) + ) +} + +@Composable +fun LineIcon( + lineName: String, + lineType: LineType?, + lineColor: android.graphics.Color?, + hasDeparted: Boolean, + modifier: Modifier = Modifier +) { + val dark = LocalDarkTheme.current + val color = + if (lineColor == null) MaterialTheme.colorScheme.primary + else Color(harmonize(lineColor.toArgb(), MaterialTheme.colorScheme.primary.toArgb())) Row( modifier = modifier .wrapContentWidth(Alignment.Start) .background( - lineBg, + color.atTone(if (dark) 80 else 40), MaterialTheme.shapes.small ) .padding(top = 4.dp, bottom = 4.dp, start = 4.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = when (type) { - LineType.Bus -> Icons.Rounded.DirectionsBus - LineType.Tram -> Icons.Rounded.Tram - LineType.Subway -> Icons.Rounded.Subway - LineType.Monorail -> Icons.Rounded.DirectionsTransit - LineType.CommuterTrain -> Icons.Rounded.DirectionsRailway - LineType.Train, LineType.RegionalTrain, LineType.HighSpeedTrain -> Icons.Rounded.Train - LineType.Boat -> Icons.Rounded.DirectionsBoat - LineType.CableCar -> Icons.Rounded.CableCar - LineType.Airplane -> Icons.Rounded.AirplanemodeActive - null -> Icons.Rounded.Commute - }, - contentDescription = type?.name, // TODO localize (maybe) with ?.let{ stringResource("departure_line_type_$it") } - tint = lineFg, - modifier = Modifier + val foregroundColor = color.atTone(if (dark) 20 else 100) + LineTypeIcon( + lineType, + foregroundColor, + Modifier .padding(end = 2.dp) - .size(16.dp), + .size(16.dp) ) - MarqueeText( - text = line, + LineMarqueeText( + lineName, + foregroundColor, style = MaterialTheme.typography.labelSmall, - color = lineFg, - textAlign = TextAlign.Center, - fadeLeft = 2.5.dp, - fadeRight = 2.5.dp, - iterations = Int.MAX_VALUE, - repeatDelayMillis = 0, - spacing = MarqueeSpacing(10.dp), - velocity = 20.dp, modifier = Modifier .wrapContentSize() .widthIn(max = 34.dp) @@ -965,31 +1151,53 @@ fun Departure.LineIcon( } @Composable -fun Departure.LazyColumnPart( +fun LineIcon( + departure: Departure, + modifier: Modifier = Modifier, +) = LineIcon( + lineName = departure.line, + lineType = departure.type, + lineColor = departure.lineColor, + hasDeparted = ZonedDateTime.now().isAfter(departure.time + (departure.delay ?: Duration.ZERO)), + modifier = modifier +) + + +@Composable +fun DepartureRow( + departure: Departure, lineWidth: Int?, - modifier: Modifier + withIcon: Boolean, + minutesInsteadOfTime: Boolean, + modifier: Modifier = Modifier, ) { + val context = LocalContext.current Row( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(0.8f) + // HACK: the extents of this row should be properly calculated. + // but how? + modifier = Modifier.fillMaxWidth(0.66f) ) { - LineIcon( - Modifier - .padding(end = 8.dp) - .widthIn( - min = if (lineWidth == null) 0.dp - else max(64.dp, lineWidth * 8.dp) - ) - ) - if (lastStop != null) { + if (withIcon) { + LineIcon( + departure = departure, + Modifier + .padding(end = 8.dp) + .widthIn( + min = if (lineWidth == null) 0.dp + else max(64.dp, lineWidth * 8.dp) + ) + ) + } + if (departure.lastStop != null) { MarqueeText( - text = lastStop!!, + text = departure.lastStop!!, style = MaterialTheme.typography.labelMedium, iterations = Int.MAX_VALUE, repeatDelayMillis = 0, @@ -1004,23 +1212,29 @@ fun Departure.LazyColumnPart( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = time.format( - DateTimeFormatter.ofPattern( - "HH:mm", - Locale.getDefault() + text = if (minutesInsteadOfTime) { + key(LocalTime.current) { departureInMinutes(context, departure) } + } else { + departure.time.format( + DateTimeFormatter.ofPattern( + "HH:mm", + Locale.getDefault() + ) ) - ), + }, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(end = 2.dp) ) - val delayMinutes = delay?.toMinutes() - if (null != delayMinutes && 0L < delayMinutes) { - Text( - text = "+$delayMinutes", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, - fontSize = TextUnit(2f, TextUnitType.Em), - ) + if (!minutesInsteadOfTime) { + val delayMinutes = departure.delay?.toMinutes() + if (null != delayMinutes && 0L < delayMinutes) { + Text( + text = "+$delayMinutes", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + fontSize = TextUnit(2f, TextUnitType.Em), + ) + } } } } @@ -1062,4 +1276,27 @@ private fun Attribution( ) } } +} + +private fun departureInMinutes(context: Context, departure: Departure): String { + val delayedDepartureTime = + departure.time + (departure.delay + ?: Duration.ZERO) + val now = ZonedDateTime.now() + + if (delayedDepartureTime < now) + return context.getString(R.string.departure_time_departed) + + val timeLeft = + Duration.between(now, delayedDepartureTime) + .toMinutes().toInt() + return if (timeLeft < 1) { + context.getString(R.string.departure_time_now) + } else { + context.resources.getQuantityString( + R.plurals.departure_time_in, + timeLeft, + timeLeft + ) + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt index 364b0d6b..59e389c0 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/MapTiles.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.absoluteOffset -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -38,7 +37,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,6 +49,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset @@ -75,6 +77,7 @@ import de.mm20.launcher2.ui.ktx.contrast import de.mm20.launcher2.ui.ktx.hue import de.mm20.launcher2.ui.ktx.hueRotate import de.mm20.launcher2.ui.ktx.invert +import de.mm20.launcher2.ui.ktx.toDp import de.mm20.launcher2.ui.locals.LocalDarkTheme import org.koin.android.ext.koin.androidContext import org.koin.core.component.KoinComponent @@ -166,18 +169,19 @@ fun MapTiles( fadeOut() + scaleOut(targetScale = scale) } ) { (start, stop, zoom) -> - val sideLength = stop.x - start.x + 1 - Column(modifier = Modifier.fillMaxWidth()) { + var tileWidth by remember { mutableIntStateOf(0) } + Column(modifier = Modifier + .fillMaxWidth() + // Needed to force all tiles to be the _exact_ same size. With weight(1f) we get rounding errors and gaps. + .onSizeChanged { tileWidth = it.width / (stop.x - start.x + 1) } + ) { for (y in start.y..stop.y) { - Row( - modifier = Modifier - .fillMaxWidth() - ) { + Row(modifier = Modifier.fillMaxWidth()) { for (x in start.x..stop.x) { AsyncImage( modifier = Modifier - .weight(1f / sideLength) - .aspectRatio(1f) + .width(tileWidth.toDp()) + .height(tileWidth.toDp()) .background(MaterialTheme.colorScheme.secondaryContainer), imageLoader = MapTileLoader.loader, model = MapTileLoader.getTileRequest(tileServerUrl, x, y, zoom), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt index c05688b8..2416990c 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.rounded.Height import androidx.compose.material.icons.rounded.HorizontalSplit import androidx.compose.material.icons.rounded.LightMode import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.Timer import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.VerticalSplit @@ -67,6 +68,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import de.mm20.launcher2.preferences.ClockWidgetAlignment import de.mm20.launcher2.preferences.ClockWidgetColors import de.mm20.launcher2.preferences.ClockWidgetStyle +import de.mm20.launcher2.preferences.TimeFormat import de.mm20.launcher2.preferences.ui.ClockWidgetSettings import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.base.LocalTime @@ -83,6 +85,7 @@ import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.SegmentClock import de.mm20.launcher2.ui.launcher.widgets.clock.parts.PartProvider import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper import de.mm20.launcher2.ui.settings.clockwidget.ClockWidgetSettingsScreenVM +import de.mm20.launcher2.ui.utils.isTwentyFourHours import org.koin.androidx.compose.inject @Composable @@ -164,7 +167,8 @@ fun ClockWidget( Box( modifier = Modifier .then(if (fillScreenHeight) Modifier.weight(1f) else Modifier) - .fillMaxWidth().padding(horizontal = if (compact == true) 0.dp else 24.dp), + .fillMaxWidth() + .padding(horizontal = if (compact == true) 0.dp else 24.dp), contentAlignment = when (alignment) { ClockWidgetAlignment.Center -> Alignment.Center ClockWidgetAlignment.Top -> Alignment.TopCenter @@ -265,34 +269,42 @@ fun Clock( darkColors: Boolean = false ) { val time = LocalTime.current + val context = LocalContext.current val clockSettings: ClockWidgetSettings by inject() val showSeconds by clockSettings.showSeconds.collectAsState(initial = false) val useThemeColor by clockSettings.useThemeColor.collectAsState(initial = false) + val timeFormat by clockSettings.timeFormat.collectAsState(null) + + if (timeFormat == null) return + + val isTwentyFourHours = timeFormat!!.isTwentyFourHours(context) when (style) { is ClockWidgetStyle.Digital1 -> DigitalClock1( - time, - style, - compact, - showSeconds, - useThemeColor, - darkColors + time = time, + compact = compact, + showSeconds = showSeconds, + twentyFourHours = isTwentyFourHours, + useThemeColor = useThemeColor, + darkColors = darkColors, ) is ClockWidgetStyle.Digital2 -> DigitalClock2( - time, - compact, - showSeconds, - useThemeColor, - darkColors + time = time, + compact = compact, + showSeconds = showSeconds, + twentyFourHours = isTwentyFourHours, + useThemeColor = useThemeColor, + darkColors = darkColors, ) is ClockWidgetStyle.Binary -> BinaryClock( - time, - compact, - showSeconds, - useThemeColor, - darkColors + time = time, + compact = compact, + showSeconds = showSeconds, + twentyFourHours = isTwentyFourHours, + useThemeColor = useThemeColor, + darkColors = darkColors, ) is ClockWidgetStyle.Analog -> AnalogClock( @@ -304,19 +316,21 @@ fun Clock( ) is ClockWidgetStyle.Orbit -> OrbitClock( - time, - compact, - showSeconds, - useThemeColor, - darkColors + time = time, + compact = compact, + showSeconds = showSeconds, + twentyFourHours = isTwentyFourHours, + useThemeColor = useThemeColor, + darkColors = darkColors, ) is ClockWidgetStyle.Segment -> SegmentClock( - time, - compact, - showSeconds, - useThemeColor, - darkColors + time = time, + compact = compact, + showSeconds = showSeconds, + twentyFourHours = isTwentyFourHours, + useThemeColor = useThemeColor, + darkColors = darkColors, ) is ClockWidgetStyle.Custom -> CustomClock(style, compact, useThemeColor, darkColors) @@ -349,6 +363,7 @@ fun ConfigureClockWidgetSheet( val fillHeight by viewModel.fillHeight.collectAsState() val alignment by viewModel.alignment.collectAsState() val showSeconds by viewModel.showSeconds.collectAsState() + val timeFormat by viewModel.timeFormat.collectAsState() val useAccentColor by viewModel.useThemeColor.collectAsState() val parts by viewModel.parts.collectAsState() @@ -488,13 +503,63 @@ fun ConfigureClockWidgetSheet( AnimatedVisibility(compact == false && style !is ClockWidgetStyle.Custom) { SwitchPreference( title = stringResource(R.string.preference_clock_widget_show_seconds), - icon = Icons.Rounded.AccessTime, + icon = Icons.Rounded.Timer, value = showSeconds, onValueChanged = { viewModel.setShowSeconds(it) } ) } + AnimatedVisibility( + style !is ClockWidgetStyle.Analog && + style !is ClockWidgetStyle.Custom && + style !is ClockWidgetStyle.Empty + ) { + var showDropdown by remember { mutableStateOf(false) } + Preference( + title = stringResource(R.string.preference_clock_widget_time_format), + summary = when (timeFormat) { + TimeFormat.TwelveHour -> stringResource(R.string.preference_clock_widget_time_format_12h) + TimeFormat.TwentyFourHour -> stringResource(R.string.preference_clock_widget_time_format_24h) + TimeFormat.System -> stringResource(R.string.preference_clock_widget_time_format_system) + }, + icon = Icons.Rounded.AccessTime, + onClick = { + showDropdown = true + } + ) + DropdownMenu( + expanded = showDropdown, + onDismissRequest = { showDropdown = false }) { + DropdownMenuItem( + text = { + Text(stringResource(R.string.preference_clock_widget_time_format_system)) + }, + onClick = { + viewModel.setTimeFormat(TimeFormat.System) + showDropdown = false + } + ) + DropdownMenuItem( + text = { + Text(stringResource(R.string.preference_clock_widget_time_format_24h)) + }, + onClick = { + viewModel.setTimeFormat(TimeFormat.TwentyFourHour) + showDropdown = false + } + ) + DropdownMenuItem( + text = { + Text(stringResource(R.string.preference_clock_widget_time_format_12h)) + }, + onClick = { + viewModel.setTimeFormat(TimeFormat.TwelveHour) + showDropdown = false + } + ) + } + } } } OutlinedCard( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/BinaryClock.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/BinaryClock.kt index 7eeb70f3..72b39ad9 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/BinaryClock.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/BinaryClock.kt @@ -13,14 +13,19 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import de.mm20.launcher2.preferences.ClockWidgetStyle +import de.mm20.launcher2.preferences.TimeFormat import de.mm20.launcher2.ui.locals.LocalDarkTheme +import de.mm20.launcher2.ui.utils.isTwentyFourHours import java.util.Calendar @Composable fun BinaryClock( time: Long, compact: Boolean, + twentyFourHours: Boolean, showSeconds: Boolean, useThemeColor: Boolean, darkColors: Boolean, @@ -30,8 +35,8 @@ fun BinaryClock( date.timeInMillis = time val second = date[Calendar.SECOND] val minute = date[Calendar.MINUTE] - var hour = date[Calendar.HOUR] - if (hour == 0) hour = 12 + var hour = date[if(!twentyFourHours) Calendar.HOUR else Calendar.HOUR_OF_DAY] + if (!twentyFourHours && hour == 0) hour = 12 val color = if (useThemeColor) { if (!darkColors) { @@ -56,11 +61,11 @@ fun BinaryClock( Row( modifier = Modifier.padding(start = 0.dp, top = 24.dp, end = 0.dp, bottom = 6.dp) ) { - for (i in 0 until 10) { - val active = if (i < 4) { - hour and (1 shl (3 - i)) != 0 + for (i in 0 until if (twentyFourHours) 11 else 10) { + val active = if (i < if (twentyFourHours) 5 else 4) { + hour and (1 shl ((if (twentyFourHours) 4 else 3) - i)) != 0 } else { - minute and (1 shl (9 - i)) != 0 + minute and (1 shl ((if (twentyFourHours) 10 else 9) - i)) != 0 } Box( modifier = Modifier @@ -70,7 +75,7 @@ fun BinaryClock( if (active) color else disabledColor ) ) - if (i == 3) { + if (i == if (twentyFourHours) 4 else 3) { Box(Modifier.size(8.dp)) } } @@ -98,8 +103,8 @@ fun BinaryClock( horizontalAlignment = Alignment.End ) { Row { - for (i in 0 until 4) { - val active = hour and (1 shl (3 - i)) != 0 + for (i in 0 until if (twentyFourHours) 5 else 4) { + val active = hour and (1 shl ((if (twentyFourHours) 4 else 3) - i)) != 0 Box( modifier = Modifier .padding( 4.dp) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/DigitalClock1.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/DigitalClock1.kt index 04cb2eee..b6b97790 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/DigitalClock1.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/DigitalClock1.kt @@ -33,6 +33,7 @@ fun DigitalClock1( time: Long, style: ClockWidgetStyle.Digital1 = ClockWidgetStyle.Digital1(), compact: Boolean, + twentyFourHours: Boolean, showSeconds: Boolean, useThemeColor: Boolean, darkColors: Boolean, @@ -40,10 +41,10 @@ fun DigitalClock1( val verticalLayout = !compact val format = SimpleDateFormat( when { - DateFormat.is24HourFormat(LocalContext.current) && verticalLayout -> { + twentyFourHours && verticalLayout -> { "HH\nmm" } - DateFormat.is24HourFormat(LocalContext.current) -> { + twentyFourHours -> { "HH mm" } verticalLayout -> { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/DigitalClock2.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/DigitalClock2.kt index 63746452..5050e710 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/DigitalClock2.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/DigitalClock2.kt @@ -21,6 +21,7 @@ fun DigitalClock2( time: Long, compact: Boolean, showSeconds: Boolean, + twentyFourHours: Boolean, useThemeColor: Boolean, darkColors: Boolean, ) { @@ -40,14 +41,14 @@ fun DigitalClock2( } val formatString = if (verticalLayout && showSeconds) { - if (DateFormat.is24HourFormat(LocalContext.current)) { + if (twentyFourHours) { "HH:mm:ss" } else { "hh:mm:ss" } } else { - if (DateFormat.is24HourFormat(LocalContext.current)) { + if (twentyFourHours) { "HH:mm" } else { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/OrbitClock.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/OrbitClock.kt index cea5ddde..1305f906 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/OrbitClock.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/OrbitClock.kt @@ -48,6 +48,7 @@ fun OrbitClock( time: Long, compact: Boolean, showSeconds: Boolean, + twentyFourHours: Boolean, useThemeColor: Boolean, darkColors: Boolean, ) { @@ -59,7 +60,7 @@ fun OrbitClock( val minute = parsed.minute val hour = parsed.hour val formattedHour = ( - if (DateFormat.is24HourFormat(LocalContext.current)) + if (twentyFourHours) hour else { ((hour + 11) % 12) + 1 diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/SegmentClock.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/SegmentClock.kt index 5120ca28..524a8934 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/SegmentClock.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/SegmentClock.kt @@ -52,11 +52,12 @@ fun SegmentClock( time: Long, compact: Boolean, showSeconds: Boolean, + twentyFourHours: Boolean, useThemeColor: Boolean, darkColors: Boolean, ) { val parsed = Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()) - val hour = if (DateFormat.is24HourFormat(LocalContext.current)) parsed.hour else (((parsed.hour + 11) % 12) + 1) + val hour = if (twentyFourHours) parsed.hour else (((parsed.hour + 11) % 12) + 1) val minute = parsed.minute val second = parsed.second diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index 5c54368b..e058b85e 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -38,10 +38,12 @@ import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen +import de.mm20.launcher2.ui.settings.calendarsearch.CalendarProviderSettingsScreen import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsScreen import de.mm20.launcher2.ui.settings.cards.CardsSettingsScreen import de.mm20.launcher2.ui.settings.colorscheme.ThemeSettingsScreen import de.mm20.launcher2.ui.settings.colorscheme.ThemesSettingsScreen +import de.mm20.launcher2.ui.settings.contacts.ContactsSettingsScreen import de.mm20.launcher2.ui.settings.crashreporter.CrashReportScreen import de.mm20.launcher2.ui.settings.crashreporter.CrashReporterScreen import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen @@ -91,16 +93,18 @@ class SettingsActivity : BaseActivity() { val navController = rememberNavController() LaunchedEffect(route) { - try { - navController.navigate(route ?: "settings") { - popUpTo("settings") { - inclusive = true + if (route != null) { + try { + navController.navigate(route ?: "settings") { + popUpTo("settings") { + inclusive = true + } } - } - } catch (e: IllegalArgumentException) { - navController.navigate("settings") { - popUpTo("settings") { - inclusive = true + } catch (e: IllegalArgumentException) { + navController.navigate("settings") { + popUpTo("settings") { + inclusive = true + } } } } @@ -198,6 +202,11 @@ class SettingsActivity : BaseActivity() { composable("settings/search/calendar") { CalendarSearchSettingsScreen() } + composable("settings/search/calendar/{providerId}") { + CalendarProviderSettingsScreen( + it.arguments?.getString("providerId") ?: return@composable + ) + } composable("settings/search/searchactions") { SearchActionsSettingsScreen() } @@ -219,6 +228,9 @@ class SettingsActivity : BaseActivity() { composable("settings/favorites") { FavoritesSettingsScreen() } + composable("settings/search/contacts") { + ContactsSettingsScreen() + } composable("settings/integrations") { IntegrationsSettingsScreen() } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreen.kt new file mode 100644 index 00000000..4b645fae --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreen.kt @@ -0,0 +1,97 @@ +package de.mm20.launcher2.ui.settings.calendarsearch + +import android.app.PendingIntent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.calendar.providers.CalendarList +import de.mm20.launcher2.crashreporter.CrashReporter +import de.mm20.launcher2.ktx.sendWithBackgroundPermission +import de.mm20.launcher2.plugin.PluginState +import de.mm20.launcher2.themes.atTone +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.Banner +import de.mm20.launcher2.ui.component.preferences.CheckboxPreference +import de.mm20.launcher2.ui.component.preferences.PreferenceCategory +import de.mm20.launcher2.ui.component.preferences.PreferenceScreen +import de.mm20.launcher2.ui.component.preferences.SwitchPreference +import de.mm20.launcher2.ui.locals.LocalDarkTheme + +@Composable +fun CalendarProviderSettingsScreen(providerId: String) { + val viewModel = viewModel() + LaunchedEffect(providerId) { + viewModel.init(providerId) + } + + val enabled by viewModel.isProviderEnabled.collectAsStateWithLifecycle(false) + val calendarLists by viewModel.calendarLists.collectAsStateWithLifecycle(sortedMapOf>()) + val excludedCalendars by viewModel.excludedCalendars.collectAsStateWithLifecycle(emptySet()) + + val pluginState by viewModel.pluginState.collectAsStateWithLifecycle(null) + + val providerAvailable = providerId == "local" || pluginState != null + + PreferenceScreen( + title = pluginState?.plugin?.label ?: stringResource(R.string.preference_search_calendar) + ) { + if (!providerAvailable) { + return@PreferenceScreen + } + item { + PreferenceCategory { + SwitchPreference( + title = + if (providerId == "local") stringResource(R.string.preference_search_calendar) + else pluginState?.plugin?.label ?: "", + summary = + if (providerId == "local") stringResource(R.string.preference_search_local_calendar_summary) + else (pluginState?.state as? PluginState.Ready)?.text + ?: pluginState?.plugin?.description, + value = enabled && (pluginState == null || pluginState?.state is PluginState.Ready), + onValueChanged = { viewModel.setProviderEnabled(providerId, it) } + ) + } + } + items(calendarLists.toList()) { (k, v) -> + PreferenceCategory( + title = k, + ) { + for (list in v) { + CheckboxPreference( + title = list.name, + value = !excludedCalendars.contains(list.id), + onValueChanged = { viewModel.setCalendarExcluded(list.id, !it) }, + checkboxColors = CheckboxDefaults.colors( + checkedColor = if (list.color == 0) MaterialTheme.colorScheme.primary + else Color( + list.color.atTone(if (LocalDarkTheme.current) 80 else 40) + ), + checkmarkColor = if (list.color == 0) MaterialTheme.colorScheme.onPrimary + else Color( + list.color.atTone(if (LocalDarkTheme.current) 20 else 100) + ) + ), + enabled = enabled, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreenVM.kt new file mode 100644 index 00000000..d23de9ce --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreenVM.kt @@ -0,0 +1,42 @@ +package de.mm20.launcher2.ui.settings.calendarsearch + +import androidx.lifecycle.ViewModel +import de.mm20.launcher2.calendar.CalendarRepository +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.plugins.PluginService +import de.mm20.launcher2.preferences.search.CalendarSearchSettings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class CalendarProviderSettingsScreenVM: ViewModel(), KoinComponent { + private val providerId = MutableStateFlow("") + + fun init(providerId: String) { + this.providerId.value = providerId + } + + private val calendarSearchSettings: CalendarSearchSettings by inject() + private val calendarRepository: CalendarRepository by inject() + private val pluginService: PluginService by inject() + + val pluginState = providerId.flatMapLatest { pluginService.getPluginWithState(it) } + + val isProviderEnabled = providerId.flatMapLatest { calendarSearchSettings.isProviderEnabled(it) } + fun setProviderEnabled(providerId: String, enabled: Boolean) { + calendarSearchSettings.setProviderEnabled(providerId, enabled) + } + + val calendarLists = providerId + .flatMapLatest { calendarRepository.getCalendars(it) } + .map { it.groupBy { it.owner }.toSortedMap(compareBy { it }) } + + val excludedCalendars = calendarSearchSettings.excludedCalendars + + fun setCalendarExcluded(calendarId: String, excluded: Boolean) { + calendarSearchSettings.setCalendarExcluded(calendarId, excluded) + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreen.kt index e3be947b..dbf9567e 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreen.kt @@ -1,32 +1,21 @@ package de.mm20.launcher2.ui.settings.calendarsearch import android.app.PendingIntent -import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CalendarToday -import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.ErrorOutline -import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -35,163 +24,83 @@ import androidx.lifecycle.viewmodel.compose.viewModel import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.ktx.sendWithBackgroundPermission import de.mm20.launcher2.plugin.PluginState -import de.mm20.launcher2.search.calendar.CalendarListType -import de.mm20.launcher2.themes.atTone import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.Banner import de.mm20.launcher2.ui.component.MissingPermissionBanner -import de.mm20.launcher2.ui.component.preferences.CheckboxPreference import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceScreen import de.mm20.launcher2.ui.component.preferences.PreferenceWithSwitch -import de.mm20.launcher2.ui.locals.LocalDarkTheme +import de.mm20.launcher2.ui.locals.LocalNavController @Composable fun CalendarSearchSettingsScreen() { val viewModel: CalendarSearchSettingsScreenVM = viewModel() val context = LocalContext.current + val navController = LocalNavController.current val hasCalendarPermission by viewModel.hasCalendarPermission.collectAsState(null) - val plugins by viewModel.availablePlugins.collectAsState(emptyList()) + val plugins by viewModel.availablePlugins.collectAsStateWithLifecycle(emptyList(), minActiveState = Lifecycle.State.RESUMED) val enabledProviders by viewModel.enabledProviders.collectAsState(emptySet()) - val calendarLists by viewModel.calendarLists.collectAsStateWithLifecycle( - null, - minActiveState = Lifecycle.State.RESUMED - ) - val excludedCalendars by viewModel.excludedCalendars.collectAsState(emptyList()) - - var showDialogForProvider by remember { mutableStateOf(null) } - PreferenceScreen(title = stringResource(R.string.preference_search_calendar)) { item { - AnimatedVisibility(hasCalendarPermission == false) { - MissingPermissionBanner( - text = stringResource(R.string.missing_permission_calendar_search_settings), - onClick = { - viewModel.requestCalendarPermission(context as AppCompatActivity) - }, - modifier = Modifier.padding(16.dp) - ) - } - val selectedCalendars = remember(excludedCalendars, calendarLists) { - calendarLists?.count { it.providerId == "local" } - ?.minus(excludedCalendars.count { - it.startsWith("local:") - }) - } - PreferenceWithSwitch( - title = stringResource(R.string.preference_search_calendar), - summary = if (selectedCalendars != null && calendarLists != null) "$selectedCalendars lists selected" - else stringResource(R.string.preference_search_calendar_summary), - switchValue = enabledProviders.contains("local") && hasCalendarPermission == true, - onSwitchChanged = { - viewModel.setProviderEnabled("local", it) - }, - enabled = hasCalendarPermission == true, - onClick = { - showDialogForProvider = "local" - } - ) - for (plugin in plugins) { - val state = plugin.state - if (state is PluginState.SetupRequired) { - Banner( - modifier = Modifier.padding(16.dp), - text = state.message - ?: stringResource(id = R.string.plugin_state_setup_required), - icon = Icons.Rounded.ErrorOutline, - primaryAction = { - TextButton(onClick = { - try { - state.setupActivity.sendWithBackgroundPermission(context) - } catch (e: PendingIntent.CanceledException) { - CrashReporter.logException(e) - } - }) { - Text(stringResource(id = R.string.plugin_action_setup)) - } - } + PreferenceCategory { + AnimatedVisibility(hasCalendarPermission == false) { + MissingPermissionBanner( + text = stringResource(R.string.missing_permission_calendar_search_settings), + onClick = { + viewModel.requestCalendarPermission(context as AppCompatActivity) + }, + modifier = Modifier.padding(16.dp) ) } - val selectedCalendars = remember(excludedCalendars, calendarLists) { - calendarLists?.count { it.providerId == plugin.plugin.authority } - ?.minus(excludedCalendars.count { - it.startsWith( - "${plugin.plugin.authority}:" - ) - }) - } PreferenceWithSwitch( - title = plugin.plugin.label, - enabled = state is PluginState.Ready, - summary = (state as? PluginState.SetupRequired)?.message - ?: if (selectedCalendars != null && calendarLists != null) { - pluralStringResource( - R.plurals.calendar_search_enabled_lists, - selectedCalendars, - selectedCalendars - ) - } - else (state as? PluginState.Ready)?.text ?: plugin.plugin.description, - switchValue = enabledProviders.contains(plugin.plugin.authority) && state is PluginState.Ready, + title = stringResource(R.string.preference_search_calendar), + summary = stringResource(R.string.preference_search_local_calendar_summary), + switchValue = enabledProviders.contains("local") && hasCalendarPermission == true, onSwitchChanged = { - viewModel.setProviderEnabled(plugin.plugin.authority, it) + viewModel.setProviderEnabled("local", it) }, + enabled = hasCalendarPermission == true, onClick = { - showDialogForProvider = plugin.plugin.authority + navController?.navigate("settings/search/calendar/local") } ) - } - } - } - - Log.d("MM20", "${calendarLists.toString()}") - - val dialogCalendarLists by remember { - derivedStateOf { - if (showDialogForProvider == null) null - else calendarLists?.filter { it.providerId == showDialogForProvider } - } - } - - if (showDialogForProvider != null && dialogCalendarLists != null) { - ModalBottomSheet( - onDismissRequest = { - showDialogForProvider = null - }, - ) { - val groups = remember(dialogCalendarLists) { - dialogCalendarLists!!.groupBy { it.owner }.entries.sortedBy { it.key } - } - - LazyColumn { - items(groups) { - PreferenceCategory( - title = it.key, - iconPadding = false, - ) { - for (list in it.value) { - CheckboxPreference( - title = list.name, - iconPadding = false, - value = list.id !in excludedCalendars, - onValueChanged = { value -> - viewModel.setCalendarExcluded(list.id, !value) - }, - checkboxColors = CheckboxDefaults.colors( - checkedColor = if (list.color == 0) MaterialTheme.colorScheme.primary - else Color( - list.color.atTone(if (LocalDarkTheme.current) 80 else 40) - ), - checkmarkColor = if (list.color == 0) MaterialTheme.colorScheme.onPrimary - else Color( - list.color.atTone(if (LocalDarkTheme.current) 20 else 100) - ) - ) - ) - } + for (plugin in plugins) { + val state = plugin.state + if (state is PluginState.SetupRequired) { + Banner( + modifier = Modifier.padding(16.dp), + text = state.message + ?: stringResource(id = R.string.plugin_state_setup_required), + icon = Icons.Rounded.ErrorOutline, + primaryAction = { + TextButton(onClick = { + try { + state.setupActivity.sendWithBackgroundPermission(context) + } catch (e: PendingIntent.CanceledException) { + CrashReporter.logException(e) + } + }) { + Text(stringResource(id = R.string.plugin_action_setup)) + } + } + ) } + PreferenceWithSwitch( + title = plugin.plugin.label, + enabled = state is PluginState.Ready, + summary = (state as? PluginState.SetupRequired)?.message + ?: (state as? PluginState.Ready)?.text + ?: plugin.plugin.description, + switchValue = enabledProviders.contains(plugin.plugin.authority) && state is PluginState.Ready, + onSwitchChanged = { + viewModel.setProviderEnabled(plugin.plugin.authority, it) + }, + onClick = { + navController?.navigate("settings/search/calendar/${plugin.plugin.authority}") + } + ) } } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt index 6781420e..e123b7f4 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import de.mm20.launcher2.preferences.ClockWidgetAlignment import de.mm20.launcher2.preferences.ClockWidgetColors import de.mm20.launcher2.preferences.ClockWidgetStyle +import de.mm20.launcher2.preferences.TimeFormat import de.mm20.launcher2.preferences.ui.ClockWidgetSettings import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -21,7 +22,7 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent { settings.setCompact(compact) } - val availableClockStyles = combine(settings.digital1, settings.custom) {digital1, custom -> + val availableClockStyles = combine(settings.digital1, settings.custom) { digital1, custom -> listOf( digital1, ClockWidgetStyle.Digital2, @@ -54,6 +55,13 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent { settings.setShowSeconds(showSeconds) } + val timeFormat = settings.timeFormat + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), TimeFormat.System) + + fun setTimeFormat(timeFormat: TimeFormat) { + settings.setTimeFormat(timeFormat) + } + val useThemeColor = settings.useThemeColor .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreen.kt new file mode 100644 index 00000000..87df1978 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreen.kt @@ -0,0 +1,58 @@ +package de.mm20.launcher2.ui.settings.contacts + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Call +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.MissingPermissionBanner +import de.mm20.launcher2.ui.component.preferences.PreferenceCategory +import de.mm20.launcher2.ui.component.preferences.PreferenceScreen +import de.mm20.launcher2.ui.component.preferences.SwitchPreference + +@Composable +fun ContactsSettingsScreen() { + val viewModel: ContactsSettingsScreenVM = viewModel() + val context = LocalContext.current + + val hasCallPermission by viewModel.hasCallPermission.collectAsStateWithLifecycle(null) + val callOnTap by viewModel.callOnTap.collectAsStateWithLifecycle(null) + + PreferenceScreen( + title = stringResource(R.string.preference_search_contacts) + ) { + item { + PreferenceCategory { + AnimatedVisibility(hasCallPermission == false) { + MissingPermissionBanner( + text = stringResource(R.string.missing_permission_call_contacts_settings), + onClick = { + viewModel.requestCallPermission(context as AppCompatActivity) + }, + modifier = Modifier.padding(16.dp) + ) + } + SwitchPreference( + title = stringResource(R.string.preference_contacts_call_on_tap), + summary = stringResource(R.string.preference_contacts_call_on_tap_summary), + icon = Icons.Rounded.Call, + value = callOnTap == true && hasCallPermission == true, + onValueChanged = { + viewModel.setCallOnTap(it) + }, + enabled = hasCallPermission == true + ) + } + } + } + +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt new file mode 100644 index 00000000..e8d84b47 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt @@ -0,0 +1,30 @@ +package de.mm20.launcher2.ui.settings.contacts + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.search.ContactSearchSettings +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class ContactsSettingsScreenVM : ViewModel(), KoinComponent { + + private val settings: ContactSearchSettings by inject() + private val permissionsManager: PermissionsManager by inject() + + val hasCallPermission = permissionsManager.hasPermission(PermissionGroup.Call) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + fun requestCallPermission(activity: AppCompatActivity) = + permissionsManager.requestPermission(activity, PermissionGroup.Call) + + val callOnTap = settings.callOnTap + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + fun setCallOnTap(callOnTap: Boolean) = + settings.setCallOnTap(callOnTap) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreen.kt index 794e916d..09092305 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreen.kt @@ -1,6 +1,5 @@ package de.mm20.launcher2.ui.settings.icons -import android.graphics.drawable.ColorDrawable import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke @@ -17,9 +16,7 @@ import androidx.compose.foundation.lazy.LazyColumn 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.material.icons.Icons -import androidx.compose.material.icons.rounded.FormatPaint import androidx.compose.material.icons.rounded.Palette import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -42,13 +39,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import de.mm20.launcher2.icons.IconPack import de.mm20.launcher2.icons.LauncherIcon -import de.mm20.launcher2.icons.StaticIconLayer -import de.mm20.launcher2.icons.StaticLauncherIcon import de.mm20.launcher2.preferences.IconShape import de.mm20.launcher2.preferences.ui.GridSettings import de.mm20.launcher2.ui.R @@ -116,6 +110,26 @@ fun IconsSettingsScreen() { viewModel.setShowLabels(it) } ) + SwitchPreference( + title = stringResource(R.string.preference_grid_list_style), + summary = stringResource(R.string.preference_grid_list_style_summary), + value = grid.showList, + onValueChanged = { + viewModel.setShowList(it) + } + ) + AnimatedVisibility( + grid.showList + ) { + SwitchPreference( + title = stringResource(R.string.preference_grid_list_icons), + summary = stringResource(R.string.preference_grid_list_icons_summary), + value = grid.showListIcons, + onValueChanged = { + viewModel.setShowListIcons(it) + } + ) + } SliderPreference( title = stringResource(R.string.preference_grid_column_count), value = grid.columnCount, @@ -430,30 +444,14 @@ fun IconShapePreference( .padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - val context = LocalContext.current - ShapedLauncherIcon( - size = 48.dp, - icon = { - StaticLauncherIcon( - foregroundLayer = StaticIconLayer( - icon = ContextCompat.getDrawable( - context, - R.mipmap.ic_launcher_foreground - )!!, - scale = 1.5f, - ), - backgroundLayer = StaticIconLayer( - icon = ColorDrawable( - context.getColor(R.color.ic_launcher_background) - ) - ) - ) - }, - modifier = Modifier.clickable { - onValueChanged(it) - showDialog = false - }, - shape = getShape(it) + Box( + modifier = Modifier.clip(getShape(it)) + .size(48.dp) + .background(MaterialTheme.colorScheme.primary) + .clickable { + onValueChanged(it) + showDialog = false + } ) Text( getShapeName(it) ?: "", diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreenVM.kt index 80a3abd0..15f01ab5 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreenVM.kt @@ -53,6 +53,14 @@ class IconsSettingsScreenVM( uiSettings.setGridShowLabels(showLabels) } + fun setShowList(showList: Boolean) { + uiSettings.setGridShowList(showList) + } + + fun setShowListIcons(showIcons: Boolean) { + uiSettings.setGridShowListIcons(showIcons) + } + val iconShape = uiSettings.iconShape fun setIconShape(iconShape: IconShape) { uiSettings.setIconShape(iconShape) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt index cc5cc8b6..2970b777 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt @@ -6,7 +6,6 @@ import de.mm20.launcher2.plugin.PluginType import de.mm20.launcher2.plugins.PluginService import de.mm20.launcher2.preferences.search.LocationSearchSettings import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import org.koin.core.component.KoinComponent import org.koin.core.component.inject diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt index 15cd0b7b..89c2cb9d 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt @@ -3,6 +3,7 @@ package de.mm20.launcher2.ui.settings.plugins import android.app.Activity import android.app.PendingIntent import android.content.Intent +import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity @@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile @@ -77,7 +79,7 @@ import de.mm20.launcher2.ui.locals.LocalNavController @Composable fun PluginSettingsScreen(pluginId: String) { val navController = LocalNavController.current - val activity = LocalContext.current as AppCompatActivity + val activity = LocalActivity.current val context = LocalContext.current val viewModel: PluginSettingsScreenVM = viewModel() LaunchedEffect(pluginId) { @@ -142,7 +144,7 @@ fun PluginSettingsScreen(pluginId: String) { navigationIcon = { IconButton(onClick = { if (navController?.navigateUp() != true) { - activity.onBackPressed() + activity?.onBackPressed() } }) { Icon( @@ -155,7 +157,7 @@ fun PluginSettingsScreen(pluginId: String) { if (pluginPackage?.settings != null) { IconButton(onClick = { pluginPackage?.settings?.let { - activity.startActivity(it) + activity?.startActivity(it) } }) { Icon( @@ -322,7 +324,9 @@ fun PluginSettingsScreen(pluginId: String) { ) } AnimatedVisibility(pluginPackage?.enabled == true && hasPermission == true) { - Column { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { if (filePlugins.isNotEmpty()) { PreferenceCategory( stringResource(R.string.plugin_type_filesearch), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt index 1b699562..903b47a7 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt @@ -16,15 +16,13 @@ import androidx.compose.material.icons.rounded.Loop import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Place import androidx.compose.material.icons.rounded.Public -import androidx.compose.material.icons.rounded.Sort import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Tag import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material.icons.rounded.Warning -import androidx.compose.material.icons.rounded.Work import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,11 +31,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.icons.Wikipedia +import de.mm20.launcher2.plugin.PluginType import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.BottomSheetDialog import de.mm20.launcher2.ui.component.MissingPermissionBanner @@ -48,7 +46,6 @@ import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceScreen import de.mm20.launcher2.ui.component.preferences.PreferenceWithSwitch import de.mm20.launcher2.ui.component.preferences.SwitchPreference -import de.mm20.launcher2.icons.Wikipedia import de.mm20.launcher2.ui.launcher.search.filters.SearchFilters import de.mm20.launcher2.ui.locals.LocalNavController @@ -57,19 +54,40 @@ fun SearchSettingsScreen() { val viewModel: SearchSettingsScreenVM = viewModel() val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current val navController = LocalNavController.current - var showFilterEditor by remember { - mutableStateOf(false) - } + var showFilterEditor by remember { mutableStateOf(false) } + val plugins by viewModel.plugins.collectAsStateWithLifecycle(emptyList()) + val hasCalendarPlugins by remember { derivedStateOf { plugins.any { it.plugin.type == PluginType.Calendar } } } + val hasLocationPlugins by remember { derivedStateOf { plugins.any { it.plugin.type == PluginType.LocationSearch } } } + + + val hasAppShortcutsPermission by viewModel.hasAppShortcutPermission.collectAsStateWithLifecycle(null) + val hasContactsPermission by viewModel.hasContactsPermission.collectAsStateWithLifecycle(null) + val hasCalendarPermission by viewModel.hasCalendarPermission.collectAsStateWithLifecycle(null) + val hasLocationPermission by viewModel.hasLocationPermission.collectAsStateWithLifecycle(null) + + val favorites by viewModel.favorites.collectAsStateWithLifecycle(null) + val appShortcuts by viewModel.appShortcuts.collectAsStateWithLifecycle(null) + val calendar by viewModel.calendarSearch.collectAsStateWithLifecycle(null) + val places by viewModel.placesSearch.collectAsStateWithLifecycle(null) + val contacts by viewModel.contacts.collectAsStateWithLifecycle(null) + val calculator by viewModel.calculator.collectAsStateWithLifecycle(null) + val unitConverter by viewModel.unitConverter.collectAsStateWithLifecycle(null) + val wikipedia by viewModel.wikipedia.collectAsStateWithLifecycle(null) + val websites by viewModel.websites.collectAsStateWithLifecycle(null) + + + val autoFocus by viewModel.autoFocus.collectAsStateWithLifecycle(null) + val launchOnEnter by viewModel.launchOnEnter.collectAsStateWithLifecycle(null) + val reverseSearchResults by viewModel.reverseSearchResults.collectAsStateWithLifecycle(null) + val filterBar by viewModel.filterBar.collectAsStateWithLifecycle(null) PreferenceScreen(title = stringResource(R.string.preference_screen_search)) { item { PreferenceCategory { - val favorites by viewModel.favorites.collectAsStateWithLifecycle(null) PreferenceWithSwitch( title = stringResource(R.string.preference_search_favorites), summary = stringResource(R.string.preference_search_favorites_summary), @@ -92,9 +110,6 @@ fun SearchSettingsScreen() { } ) - val hasContactsPermission by viewModel.hasContactsPermission.collectAsStateWithLifecycle( - null - ) AnimatedVisibility(hasContactsPermission == false) { MissingPermissionBanner( text = stringResource(R.string.missing_permission_contact_search_settings), @@ -104,30 +119,53 @@ fun SearchSettingsScreen() { modifier = Modifier.padding(16.dp) ) } - val contacts by viewModel.contacts.collectAsStateWithLifecycle(null) - SwitchPreference( + PreferenceWithSwitch( title = stringResource(R.string.preference_search_contacts), summary = stringResource(R.string.preference_search_contacts_summary), icon = Icons.Rounded.Person, - value = contacts == true && hasContactsPermission == true, - onValueChanged = { + switchValue = contacts == true && hasContactsPermission == true, + onSwitchChanged = { viewModel.setContacts(it) }, + onClick = { + navController?.navigate("settings/search/contacts") + }, enabled = hasContactsPermission == true ) - Preference( - title = stringResource(R.string.preference_search_calendar), - summary = stringResource(R.string.preference_search_calendar_summary), - icon = Icons.Rounded.Today, - onClick = { - navController?.navigate("settings/search/calendar") - }, - ) - - val hasAppShortcutsPermission by viewModel.hasAppShortcutPermission.collectAsStateWithLifecycle( - null - ) + if (hasCalendarPlugins) { + Preference( + title = stringResource(R.string.preference_search_calendar), + summary = stringResource(R.string.preference_search_calendar_summary), + icon = Icons.Rounded.Today, + onClick = { + navController?.navigate("settings/search/calendar") + }, + ) + } else { + AnimatedVisibility(hasCalendarPermission == false) { + MissingPermissionBanner( + text = stringResource(R.string.missing_permission_calendar_search_settings), + onClick = { + viewModel.requestCalendarPermission(context as AppCompatActivity) + }, + modifier = Modifier.padding(16.dp) + ) + } + PreferenceWithSwitch( + title = stringResource(R.string.preference_search_calendar), + summary = stringResource(R.string.preference_search_calendar_summary), + switchValue = calendar == true, + onSwitchChanged = { + viewModel.setCalendarSearch(it) + }, + icon = Icons.Rounded.Today, + enabled = hasCalendarPermission == true, + onClick = { + navController?.navigate("settings/search/calendar/local") + } + ) + } AnimatedVisibility(hasAppShortcutsPermission == false) { MissingPermissionBanner( text = stringResource( @@ -140,7 +178,6 @@ fun SearchSettingsScreen() { modifier = Modifier.padding(16.dp) ) } - val appShortcuts by viewModel.appShortcuts.collectAsStateWithLifecycle(null) SwitchPreference( title = stringResource(R.string.preference_search_appshortcuts), summary = stringResource(R.string.preference_search_appshortcuts_summary), @@ -152,7 +189,6 @@ fun SearchSettingsScreen() { enabled = hasAppShortcutsPermission == true ) - val calculator by viewModel.calculator.collectAsStateWithLifecycle(null) SwitchPreference( title = stringResource(R.string.preference_search_calculator), summary = stringResource(R.string.preference_search_calculator_summary), @@ -163,7 +199,6 @@ fun SearchSettingsScreen() { } ) - val unitConverter by viewModel.unitConverter.collectAsStateWithLifecycle(null) PreferenceWithSwitch( title = stringResource(R.string.preference_search_unitconverter), summary = stringResource(R.string.preference_search_unitconverter_summary), @@ -177,7 +212,6 @@ fun SearchSettingsScreen() { } ) - val wikipedia by viewModel.wikipedia.collectAsStateWithLifecycle(null) PreferenceWithSwitch( title = stringResource(R.string.preference_search_wikipedia), summary = stringResource(R.string.preference_search_wikipedia_summary), @@ -191,7 +225,6 @@ fun SearchSettingsScreen() { } ) - val websites by viewModel.websites.collectAsStateWithLifecycle(null) SwitchPreference( title = stringResource(R.string.preference_search_websites), summary = stringResource(R.string.preference_search_websites_summary), @@ -202,14 +235,43 @@ fun SearchSettingsScreen() { } ) - Preference( - title = stringResource(R.string.preference_search_locations), - summary = stringResource(R.string.preference_search_locations_summary), - icon = Icons.Rounded.Place, - onClick = { - navController?.navigate("settings/search/locations") - } - ) + AnimatedVisibility(hasLocationPermission == false) { + MissingPermissionBanner( + text = stringResource( + R.string.missing_permission_location_search, + ), + onClick = { + viewModel.requestLocationPermission(context as AppCompatActivity) + }, + modifier = Modifier.padding(16.dp) + ) + } + + if (hasLocationPlugins) { + Preference( + title = stringResource(R.string.preference_search_locations), + summary = stringResource(R.string.preference_search_locations_summary), + icon = Icons.Rounded.Place, + enabled = hasLocationPermission == true, + onClick = { + navController?.navigate("settings/search/locations") + } + ) + } else { + PreferenceWithSwitch( + title = stringResource(R.string.preference_search_locations), + summary = stringResource(R.string.preference_search_locations_summary), + icon = Icons.Rounded.Place, + onClick = { + navController?.navigate("settings/search/locations") + }, + switchValue = places == true, + onSwitchChanged = { + viewModel.setPlacesSearch(it) + }, + enabled = hasLocationPermission == true, + ) + } Preference( title = stringResource(R.string.preference_screen_search_actions), @@ -242,7 +304,6 @@ fun SearchSettingsScreen() { } } item { - val filterBar by viewModel.filterBar.collectAsStateWithLifecycle(null) PreferenceCategory { Preference( title = stringResource(R.string.preference_default_filter), @@ -264,7 +325,7 @@ fun SearchSettingsScreen() { Preference( title = stringResource(R.string.preference_customize_filter_bar), summary = stringResource(R.string.preference_customize_filter_bar_summary), - onClick = { + onClick = { navController?.navigate("settings/search/filterbar") } ) @@ -273,7 +334,6 @@ fun SearchSettingsScreen() { } item { PreferenceCategory { - val autoFocus by viewModel.autoFocus.collectAsStateWithLifecycle(null) SwitchPreference( title = stringResource(R.string.preference_search_bar_auto_focus), summary = stringResource(R.string.preference_search_bar_auto_focus_summary), @@ -283,7 +343,6 @@ fun SearchSettingsScreen() { viewModel.setAutoFocus(it) } ) - val launchOnEnter by viewModel.launchOnEnter.collectAsStateWithLifecycle(null) SwitchPreference( title = stringResource(R.string.preference_search_bar_launch_on_enter), summary = stringResource(R.string.preference_search_bar_launch_on_enter_summary), @@ -296,9 +355,6 @@ fun SearchSettingsScreen() { } item { PreferenceCategory { - val reverseSearchResults by viewModel.reverseSearchResults.collectAsStateWithLifecycle( - null - ) ListPreference( title = stringResource(R.string.preference_layout_search_results), items = listOf( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt index bf4d1106..a3f4b265 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.plugins.PluginService import de.mm20.launcher2.preferences.search.CalculatorSearchSettings import de.mm20.launcher2.preferences.search.CalendarSearchSettings import de.mm20.launcher2.preferences.search.ContactSearchSettings @@ -30,10 +31,11 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { private val websiteSearchSettings: WebsiteSearchSettings by inject() private val unitConverterSettings: UnitConverterSettings by inject() private val calculatorSearchSettings: CalculatorSearchSettings by inject() + private val locationSearchSettings: LocationSearchSettings by inject() private val searchFilterSettings: SearchFilterSettings by inject() + private val pluginService: PluginService by inject() private val permissionsManager: PermissionsManager by inject() - private val locationSearchSettings: LocationSearchSettings by inject() val favorites = searchUiSettings.favorites .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) @@ -42,6 +44,14 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { searchUiSettings.setFavorites(favorites) } + val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + val calendarSearch = calendarSearchSettings.isProviderEnabled("local") + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + fun setCalendarSearch(enabled: Boolean) { + calendarSearchSettings.setProviderEnabled("local", enabled) + } val hasContactsPermission = permissionsManager.hasPermission(PermissionGroup.Contacts) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) @@ -52,6 +62,22 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { contactSearchSettings.setEnabled(contacts) } + val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + val placesSearch = locationSearchSettings.osmLocations + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + fun setPlacesSearch(enabled: Boolean) { + locationSearchSettings.setOsmLocations(enabled) + } + + fun requestLocationPermission(activity: AppCompatActivity) { + permissionsManager.requestPermission(activity, PermissionGroup.Location) + } + + fun requestCalendarPermission(activity: AppCompatActivity) { + permissionsManager.requestPermission(activity, PermissionGroup.Calendar) + } + fun requestContactsPermission(activity: AppCompatActivity) { permissionsManager.requestPermission(activity, PermissionGroup.Contacts) } @@ -130,4 +156,7 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { fun setSearchFilters(searchFilters: SearchFilters) { searchFilterSettings.setDefaultFilter(searchFilters) } + + val plugins = pluginService.getPluginsWithState(enabled = true) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/utils/TimeFormat.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/utils/TimeFormat.kt new file mode 100644 index 00000000..b9ec7416 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/utils/TimeFormat.kt @@ -0,0 +1,10 @@ +package de.mm20.launcher2.ui.utils + +import android.content.Context +import android.text.format.DateFormat +import de.mm20.launcher2.preferences.TimeFormat +import de.mm20.launcher2.preferences.TimeFormat.TwentyFourHour + +fun TimeFormat.isTwentyFourHours(context: Context): Boolean { + return this == TimeFormat.TwentyFourHour || this == TimeFormat.System && DateFormat.is24HourFormat(context) +} \ No newline at end of file diff --git a/core/i18n/src/main/res/values-ca/strings.xml b/core/i18n/src/main/res/values-ca/strings.xml index 6d5eec89..a3c5484a 100644 --- a/core/i18n/src/main/res/values-ca/strings.xml +++ b/core/i18n/src/main/res/values-ca/strings.xml @@ -291,7 +291,7 @@ Bateria Mostra el nivell actual de la bateria quan la bateria està baixa o s\'està carregant Alarmes - Mostra les alarmes que sonaran durant els propers 15 minuts + Mostra les alarmes que sonaran en les properes 8 hores Fes una còpia de seguretat & restaura Exporta i importa les dades del llançador Còpia de seguretat @@ -755,7 +755,7 @@ Estació de bombers Jutjats Ajuntament - Els grans radis de cerca poden alentir significativament la cerca. + Un radi de cerca gran pot alentir significativament la cerca. Restaurant asiàtic Restaurant Ramen Restaurant de sopes @@ -778,4 +778,63 @@ Pista d\'handbol Pista de voleibol Pistes d\'esquí + + La llista de %1$s seleccionada + %1$s llistes seleccionades + %1$s llistes seleccionades + + + en un minut + d\'aquí a %1$d minuts + d\'aquí a %1$d minuts + + ara + No s\'ha seleccionat cap proveïdor meteorològic o el proveïdor seleccionat no està disponible + Realitzat a %1$s + Realitzat al dia %1$s + Realitzat avui + Cerca calendaris en aquest dispositiu + Amaga les tasques completades + L\'espai privat està tancat + Següent títol + Pausa + Les aplicacions de treball estan en pausa. + Humitat + Vent + Restabliment de la icona + Una etiqueta no pot existir sense un nom. Si continueu, l\'etiqueta se suprimirà. + Fet + Més accions + Borra el camp + Amaga les etiquetes o icones d\'etiquetes per reduir l\'espai que ocupen les etiquetes + Etiquetes compactes + Calendari + Manteniu premut i arrossegueu els elements per reordenar-los + 24h + 12h + Predeterminat + Unitats suportades + Privat + + %1$s notificació + %1$s notificacions + %1$s notificacions + + Títol anterior + Jugar + Jugador + Dia anterior + L\'endemà + Crea un nou esdeveniment + Obre l\'aplicació de calendari + Tanca l\'espai privat + Precipitació + Format horari + Reactivar + Posa en pausa les aplicacions de treball + Desbloqueja + Bloquejat + S\'ha eliminat el widget + Mostra els filtres + Amaga els filtres \ No newline at end of file diff --git a/core/i18n/src/main/res/values-ca/units.xml b/core/i18n/src/main/res/values-ca/units.xml index 8ce739e3..1354518d 100644 --- a/core/i18n/src/main/res/values-ca/units.xml +++ b/core/i18n/src/main/res/values-ca/units.xml @@ -324,4 +324,17 @@ decímetres decímetres + Longitud + Massa + Velocitat + Volum + Àrea + Moneda + Dades + Taxa de bits + Pressió + Energia + Freqüència + Temperatura + Hora \ No newline at end of file diff --git a/core/i18n/src/main/res/values-cs/strings.xml b/core/i18n/src/main/res/values-cs/strings.xml index b64ddc98..67521486 100644 --- a/core/i18n/src/main/res/values-cs/strings.xml +++ b/core/i18n/src/main/res/values-cs/strings.xml @@ -827,4 +827,19 @@ Vlhkost Štítek nemůže existovat bez názvu. Pokud budete pokračovat, bude štítek odstraněn. Widget odstraněn + Formát času + 24hodinový + 12hodinový + Podle systému + nyní + + za minutu + za %1$d minuty + za %1$d minut + + Hledat kalendáře na tomto zařízení + Pro provádění hovorů je vyžadováno oprávnění k telefonu + Zavolat po klepnutí + Rovnou zahájit hovor po klepnutí na telefonní číslo + odjel \ No newline at end of file diff --git a/core/i18n/src/main/res/values-da/strings.xml b/core/i18n/src/main/res/values-da/strings.xml index 92ae8e0c..0ec5e2fe 100644 --- a/core/i18n/src/main/res/values-da/strings.xml +++ b/core/i18n/src/main/res/values-da/strings.xml @@ -437,7 +437,7 @@ Anvend form på alle ikoner, inklusive dem, der normalt ikke understøtter det Telegram-gruppe Licenseret under GNU General Public License 3.0 - Vis alarmer, der vil ringe inden for de næste 15 minutter + Vis alarmer, der ringer inden for de næste 8 timer Vis fastgjorte og ofte brugte elementer over app-gitter Anvendelse: 1,5 kg eller 4 cm >> in Vis en knap for at omarrangere favoritterne @@ -467,7 +467,7 @@ Der er ingen elementer med dette tag Vis i favoritter Antal rækker - Tags… + Tags Konfigurér hurtige handlinger og søgegenveje Ring E-mail @@ -675,4 +675,149 @@ Dagligvarebutik Frisør Bageri + + %1$s liste valgt + %1$s lister valgt + + nu + Mærkat + Understøttede enheder + Aldrig + Skjul fuldførte opgaver + + om et minut + om %1$d minutter + + Amerikansk fodboldbane + Arbejde + Bank + Mobil + Kiosk + Kirke + Ramen-restaurant + Biludlejning + Bildeling + Forfalder kl. %1$s + Forfalder d. %1$s + Forfalder i dag + Dyrehandel + Værktøjer + Vis filterlinje + Fodboldbane + Hæveautomat + Moské + Buddhistisk tempel + Sted for tilbedelse + Kebab-restaurant + Stadium + Casino + Discountbutik + Brandstation + Retten + En stor søgeradius kan gøre søgningen betydeligt langsommere. + Hjem + Afspil + Pause + Åbn afspiller + Forrige dag + Opret ny begivenhed + Sæt arbejdsapps på pause + Oplås + Log ud + Baseballbane + Nulstil ikon + Et tag kan ikke eksistere uden et navn. Hvis du fortsætter, slettes tagget. + Tennisbane + Kinesisk restaurant + Fugtighed + + %1$s notifikation + %1$s notifikationer + + Ingen vejrtjeneste valgt, eller den valgte tjeneste er ikke tilgængelig + Vind + Nedbør + Museum + Fitnesscenter + Færdig + Flere handlinger + Ryd + Online resultater + Apps + Suppe-restaurant + Brunch-restaurant + Japansk restaurant + Gymnastik + Golfbane + Tags + Vis i + Næste sang + Tilpas filterlinje + Tilpas hvilke elementer, der vises i filterlinjen + Arbejdsapps er sat på pause. + Hold og træk elementer for at omarrangere dem + Tidsformat + 24 timer + 12 timer + Systemstandard + Brugerdefineret widget + Vis filtre + Skjul filtre + Kalender + Swimmingpool + Kampsport + Ishockeybane + Håndboldbane + + %1$s e-mailadresse + %1$s e-mailadresser + + + %1$s postadresse + %1$s postadresser + + Forrige sang + Volleyballbane + Cricket-bane + Park + Monument + Statslig bygning + Rådhus + App-gitter og søgeresultater + Kalenderwidget og søgeresultater + Søgeresultater + + %1$s telefonnummer + %1$s telefonnumre + + Næste dag + Åbn kalenderapp + Fjern pause + Låst + Widget fjernet + Kompakte tags + Skjul tag-mærkater eller -ikoner for at spare plads + Privat + Tileserver-URL + Standardfilter + Tilpas standardfilteret for søgninger + Vis hurtige filtre over tastaturet + OpenStreetMap + Søg på OpenStreetMap efter butikker og andre steder i lokalområdet + Universitet + Basketballbane + Hinduistisk tempel + Pizzaria + Burger-restaurant + Bilvask + Synagoge + Asiatisk restaurant + Ladestation + Motorcykeludlejning + Galleri + Forlystelsespark + Koncertsal + Shopping + Skiløb + Søg i kalendere på denne enhed \ No newline at end of file diff --git a/core/i18n/src/main/res/values-da/units.xml b/core/i18n/src/main/res/values-da/units.xml index 655733a7..997e8ccc 100644 --- a/core/i18n/src/main/res/values-da/units.xml +++ b/core/i18n/src/main/res/values-da/units.xml @@ -229,4 +229,33 @@ tons GB + + hektar + hektar + + + kvadratmeter + kvadratmeter + + + knob + knob + + Længde + Masse + Hastighed + Volumen + Område + Valuta + Data + Bitrate + Tryk + Energi + Frekvens + Temperatur + Tid + + megabyte + megabytes + \ No newline at end of file diff --git a/core/i18n/src/main/res/values-de/strings.xml b/core/i18n/src/main/res/values-de/strings.xml index 2874da16..b587968c 100644 --- a/core/i18n/src/main/res/values-de/strings.xml +++ b/core/i18n/src/main/res/values-de/strings.xml @@ -813,4 +813,12 @@ Symbol zurücksetzen Kompakte Tags Beschriftungen oder Symbole von Tags ausblenden um den von Tags eingenommenen Platz zu reduzieren + jetzt + + in einer Minute + in %1$d Minuten + + Zum Anruf Tippen + Beim Tippen auf eine Telefonnummer diese direkt Anrufen + abgefahren \ No newline at end of file diff --git a/core/i18n/src/main/res/values-fa/strings.xml b/core/i18n/src/main/res/values-fa/strings.xml index d067e170..17380cbc 100644 --- a/core/i18n/src/main/res/values-fa/strings.xml +++ b/core/i18n/src/main/res/values-fa/strings.xml @@ -1,30 +1,30 @@ بسته - بسته شده %1$s،%2$s + بسته شده %1$s، %2$s بازشده در %1$s باز شده %1$s، %2$s هم‌رسانی %1$s پیوند پرونده این بسته پاک کردن - سنجاق در مورد علاقه ها + سنجاق در موارد دلخواه برداشتن سنجاق بازگشت اجرا شخصی‌سازی - بازکردن - نمایش در نقشه + بازکردن پرونده + مشاهده در نقشه بازکردن وبگاه - پنهان + پنهان کردن %1$s پنهان شده. واگرد وارد کردن - پاک کردن + پاک‌کردن بازکردن در برنامه تقویم جستجو - نسخه %1$s - پیکربندی + نگارش %1$s + تنظیمات کمک پس‌زمینه شمال @@ -62,12 +62,12 @@ پرونده فشرده پرونده متنی رایاکتاب - ظراحی + طراحی فرم %1$s پشتیبان %1$s پوسته %1$s پرونده - پوشه %1$s و تمام محتوای آن برای همیشه پاک می شود.ادامه دهید؟ + پوشه %1$s و تمام محتوای آن برای همیشه پاک می‌شود. ادامه دهید؟ پرونده %1$s برای همیشه پاک می شود.ادامه دهید؟ میانبر %1$s برای همیشه پاک می شود.ادامه دهید؟ https://www.youtube.com/results?search_query=${1} @@ -79,20 +79,20 @@ پاک کردن نماد پیشرفته رمزنگاری - Percent-encoding + کدبندی درصدی application/x-www-form-urlencoded هیچکدام - ویرایش ابزارک + ویرایش ابزارک‌ها آب و هوا تقویم موسیقی - مورد علاقه + دلخواه یادداشت ابزارک برنامه ناشناخته افزودن ابزارک چیزی بنویسید… یادداشت جدید - نگه داشتن + نگه‌داشتن یادداشت_%1$s نادیده گرفتن یادداشت نادیده گرفته شد. @@ -100,52 +100,52 @@ واسه آخرین بار میگم: هیچ چیز عجیب غریبی اینجا قایم نکردم تیتاب، من رو پیدا کردی. ارزشش رو داشت؟ بستن - نگه داشتن + نگه‌داشتن هم‌مانندسازی ردکردن بعدی - پاک کردن + برداشتن جایگزینی - ویرایش مورد علاقه ها + ویرایش موارد دلخواه سنجاق شده - ترتیب خودکار سنجاق شده - ترتیب دستی موارد را به اینجا بکشید - ساختن برچسب… - نشانی کارساز NextCloud + ایجاد برچسب… + نشانی کارساز Nextcloud نشانی نمیتواند خالی باشد این نشانی به یه کارساز معتبر اشاره نمیکند - نشانی کارساز OwnCloud + نشانی کارساز Owncloud گذرواژه نام کاربری ورود ناموفق: نام کاربری یا گذرواژه نادرست. نام کاربری نمیتواند خالی باشد گذرواژه نمیتواند خالی باشد ادامه - از سر باز کنی + بیانیه انکار نمایش همه امروز فردا به زودی امروز کاری نداری - همه-روزها + کل-روز %1$s در حال اجرا محتوا محتوایی در حال اجرا نیست بارانی مه باران و رعد - ابر های پراکنده - برف شدید و رعد + ابری کم + برف سنگین و رعد دسترسی به آگاه‌ساز ها برای نمایش رسانه در حال پخش لازم است دسترسی مخاطبین برای جستجو در مخاطبین لازم است ابزارک نیاز به دسترسی تقویم دارد تقویم تان را نمی‌یابید؟ دسترسی حافظه برای جستجو در پرونده های محلی لازم است دسترسی به کل حافظه برای جستجو در پرونده های محلی لازم است - دسترسی به آگاه‌ساز ها برای نمایش نشان آگاهساز لازم است(دایره رنگی گوشه برنامه دارای آگاهساز) + دسترسی به آگاه‌سازها برای نمایش نشان آگاهساز لازم است دسترسی تقویم را برای جستجو در تقویم بدهید. - بازکردن - سنجاق نشده - بیشتر بهره‌وری شده ها - برچسب ها + باز + سنجاق نشده - بیشتر به‌کار گرفته شده‌ها + برچسب‌ها آسمان صاف بسته شده در %1$s درباره برنامه @@ -158,20 +158,676 @@ در اینجا هیچ شگفتانه ای وجود ندارد، دنبال نخود سیاه نگردید. ورود مکان یافت نشد. - دسترسی به تقویم برای جستجو در تقویم لازم است + دسترسی تقویم برای جستجو در تقویم لازم است %1$s برای جستجوی در میانبرهای برنامه لازم است به عنوان برنامه خانه انتخاب شود - برای نمایش فعالیت های پیش رو دسترسی تقویم بدهید. - پاک کردن + دسترسی تقویم را برای نمایش فعالیت‌های پیش رو و رویدادها اینجا بدهید. + برداشتن یوتیوب با %1$s - +%1$d فعالیت در حال اجرا از روز های پیشین - +%1$d فعالیت در حال اجرا از روز های پیشین + +%1$d فعالیت در حال اجرا از روزهای پیشین + +%1$d فعالیت در حال اجرا از روزهای پیشین آلبوم: %1$s - برچسب های سنجاق شده اینجا نمایش داده میشوند + برچسب‌های سنجاق شده اینجا نمایش داده می‌شوند نشانی به یک کارساز معتبر اشاره نمیکند اگر ورود دو مرحله ای را روشن کرده‌اید باید از app password بهره ببرید. فعالیت بعدی دسترسی به مکان‌یاب برای یافتن خودکار مکان شما لازم است + غرب شمال غربی + مکان + عرض خط دور + شفافیت + شکل + برش‌دار + مربع + پیش‌گزیده سامانه + شکل + بارش باران سبک و رعد + squircle + بارش باران و رعد + باران و برف + باران و برف و رعد + بارش برف سبک و رعد + معتدل + افزونه‌ها + دسترسی تماس برای شروع تماس‌ها لازم است + نگارش + پیوندها + مجوزهای متن‌باز + مربع گرد + مثلث Reuleaux + دایره + قطره اشک + سنگریزه + در پوسته‌های تیره، پس‌زمینه را تیره کنید + ورود به Owncloud برای جستجوی کارساز خود + گروه تلگرام + پیش‌گزیده + ویرایش اقدام سریع + انتخاب یک برنامه برای جستجو: + نشانی وبگاه را وارد کنید: + هیچ بسته نمادی نصب نشده است + وبگاه داده شده نمی‌تواند به صورت خودکار به عنوان یک جستجوی وب وارد شود. شما می‌توانید وبگاه دیگری را امتحان کنید یا داده‌های مورد نیاز را به صورت دستی در مرحله بعدی وارد کنید. + خاموش کردن صفحه + مرکز تناسب اندام + پارک تفریحی + https://en.wikipedia.org + نقشه آزاد آب و هوا(OpenWeatherMap) + خدمات هواشناسی آلمان (فقط آلمان) + Here + تصویر حافظه + شش‌ضلعی + نوار جستجو + یکپارچه‌سازی‌ها + اجباری کردن نماد‌های پوسته‌دار + اینک + به‌کارگیری طرح رنگی برنامه برای کل نماد‌ها، شامل آن‌هایی که پشتیبانی نمی‌شوند (توصیه نمی‌شود) + جستجوی فایل‌های %1$s در گوگل درایو + بازکردن صفحه‌کلید + جستجو در بسته‌های نماد + هیچ بسته نمادی نصب نشده است + اجازه دادن + ظاهر + روشن + تیره + طرح رنگی + پیش‌گزیده + سیاه و سفید + تخته رنگ + قلم + قلم پیش‌گزیده سامانه + درباره + پیشرفته + بازکردن وبگاه + درباره برنامه و مجوزها + سفارشی‌سازی ظاهر + ارائه‌دهنده + مکان خودکار + هنگام شارژ دستگاه، پویانمایی حباب نمایش داده میشود + نمایش نشان برای برنامه‌هایی که آگاه‌ساز خوانده‌نشده دارند + به‌کارگیری شکل برای تمام نمادها شامل آن‌هایی که معمولاً از آن پشتیبانی نمی‌کنند + تحت مجوز عمومی GNU نگارش 3.0 منتشر شده است + شبکه + اندازه نماد + آلارم‌ها + جستجوی مخاطبین روی این دستگاه + جستجوی میانبرهای برنامه + جستجوی پرونده‌های %1$s + اجرایی شدن با ورود + اجرای مورد برجسته یا کنش سریع هنگام فشار دادن دکمه \'برو\' + یافته‌های جستجوی مخفی + مدیریت برنامه‌ها و یافته‌های جستجوی مخفی + ساعت، نوار جستجو، تصویر زمینه، نوارهای سامانه + گمان میرود پرونده انتخاب‌شده یک پشتیبان نباشد. آیا باور دارید پرونده درست را انتخاب کرده‌اید؟ + پرونده پیوند داده‌شده را نمی‌توان خواند. گمان میرود جابجا یا پاک‌شده است. یک نگارش از حافظه داخلی برنامه راه‌انداز بازیابی شده است. اگر یادداشت را ویرایش کنید، پرونده پیوند داده‌شده احتمالاً بازنویسی خواهد شد. + سفارشی + این میانبر به دلیل اینکه %1$s در جانگاه راه‌انداز پیش‌گزیده انتخاب نشده غیرفعال است + برچسب + پویانمایی‌ها + پویانمایی شارژ + نمایش در + نمادهای نوار ناوبری + خودکار + روشن + تیره + مخفی کردن نوار وضعیت + گوگل + پنهان کردن نوار ناوبری + بارش دوره‌ای سنگین باران و رعد + محو کردن پس‌زمینه + نمایش نشان برای پرونده‌هایی که در ابر نگه‌داری شده‌اند + نشان‌ها + نشان‌های آگاه‌ساز + اجبار شکل + مخزن F-Droid + مجوز + این برنامه نرم‌افزار آزاد است. + آب و هوا + برنامه‌های رسانه‌ای + تعداد ستون‌ها + ساعت + پیکربندی سبک ساعت و اجزای آن + رسانه + هنگامی که جلسه رسانه‌ای فعال است، نمایش کنترل‌های رسانه‌ای + باتری + نمایش سطح کنونی باتری هنگامی که باتری ضعیف است یا در حال شارژ است + تقویم + جستجوی تعهدات و رویدادهای آینده + جستجوی اسناد، عکس‌ها و سایر فایل‌های ذخیره‌شده روی این دستگاه + گزارش‌دهنده خرابی + جستجوی پرونده‌های محلی و ابری + مخاطبین + اون‌کلود + نمایش دکمه‌ای برای افزودن، پاک‌کردن و بازچینی ابزارک‌ها + تماس با لمس + شروع زمان‌سنج + هم‌رسانی + شروع مستقیم تماس‌ها با لمس شماره‌های تلفن + قالب URL + چیدمان یافته‌های جستجو + هیچ موردی به این برچسب واگذار نشده. اگر ادامه دهید، برچسب پاک خواهد شد. + از بالا به پایین + بدون حاشیه + کارت دارای پس‌زمینه + قفل شده + پوسته نقشه + تنظیم به عنوان ارائه‌دهنده آب و هوا + در حال حاضر به عنوان ارائه‌دهنده آب و هوا تنظیم شده است + باز ۲۴/۷ + دودویی + عقربه‌ای + اجاره ماشین + نمایش پالایش‌های سریع بالای صفحه‌کلید + آرایشگاه + ۷-بخشی + ابزارها + موزه + رستوران چینی + زمین فوتبال آمریکایی + رطوبت + نرخ ارز منتشر شده روزانه توسط بانک مرکزی اروپا. تمامی داده‌ها «به همان صورت» ارائه شده و بدون هیچ گونه ضمانتی است. مسئولیتی در قبال این اطلاعات پذیرفته نمی‌شود.\n\nآخرین به‌روزرسانی: %1$s + جستجوی تقویم‌ها روی این دستگاه + ورود با گوگل + به شیوه خودکار هنگام بازکردن کشوی برنامه‌ها، صفحه‌کلید نمایش داده شود + شبکه و نمادها + شبکه، اندازه نماد، بسته‌های نماد، نشان‌ها + حساب‌ها + این پشتیبان با نسخه متفاوتی از %1$s ایجاد شده است. ممکن است برخی از داده‌ها به درستی بازیابی نشوند. + این پشتیبان با نگارشی ناهمسان با %1$s ساخته شده و نمی‌تواند با این نگارش بازیابی شود. + نام + برنامه + پیشنهادات + انتخاب موارد: + قالب نشانی وبی که برای ساخت نشانی وب جستجو به‌کار میرود. ‘${1}’ را به عنوان جایگزینی برای عبارت واقعی جستجو بنویسید، مثال: https://google.com/search?q=${1}. + بالا + پایین + پایدار + متوازن + الفبایی + بر اساس استفاده + انعطاف‌پذیری رتبه‌بندی + تنظیم ابزارک + تغییر اندازه + بارگذاری ابزارک برنامه با شکست مواجه شد. + تنظیمات یکپارچگی کنترل رسانه + پیوند به پرونده + پیوند داده شده به %1$s + نگه‌داشتن انتخاب شدگان + پرونده پیوند داده شده خالی نیست و محتوای آن با آخرین نگارش نگه‌داری شده این یادداشت همخوانی ندارد. کدام نگارش را می‌خواهید نگه دارید؟ + محتوای فعلی پرونده: + خطا در خواندن پرونده پیوند داده شده + خطا در نگه‌داری یادداشت + یادداشت نتوانست به پرونده پیوند داده‌شده نوشته شود. گمان می‌رود جابجا یا پاک‌شده باشد. یک نگارش در حافظه داخلی برنامه راه‌انداز نگه‌داری شده است. + از رنگ اصلی + پالت + امپراتوری + نشانی Overpass + پنهان‌کردن مکان‌های طبقه‌بندی نشده + تنها یافته‌های در دسته‌بندی انتخابی را نمایش دهید، مانند کافه‌ها یا رستوران‌ها + نقشه + نمایش پیش‌نمایش نقشه برای مکان‌ها + اعمال طرح رنگی برنامه راه‌انداز به نقشه + URL سرور کاشی + تماس + مسیریابی + گزارش ایراد + راه‌اندازی + فروشگاه خواروبار + ابزارک سفارشی + مدار + بدون ساعت + دانشگاه + فروشگاه پوشاک + رستوران پیتزا + رستوران همبرگر + کلیسا + مسجد + عبادتگاه + معبد بودایی + معبد هندو + استخر شنا + هرگز + خانه + رستوران کباب + رستوران آسیایی + رستوران سوپ + تلفن همراه + کار + + %1$s شماره تلفن + %1$s شماره تلفن + + + %1$s رایانامه + %1$s رایانامه + + + %1$s آگاه‌ساز + %1$s آگاه‌ساز + + عنوان بعدی + پخش + ایست + بازکردن پخش‌کننده + روز پیشین + بازنشانی نماد + روز پسین + ایجاد رویداد جدید + بازکردن + قفل کردن فضای شخصی + نگه‌داشتن و درگ کردن موارد برای بازچینی آن‌ها + باد + بارندگی + + در یک دقیقه + در %1$d دقیقه + + + %1$s فهرست انتخاب‌شده + %1$s فهرست انتخاب‌شده + + سر رسید در %1$s + برف و رعد + برف سنگین + ابری + وظیفه امروز + تا تاریخ %1$s + بارش دوره‌ای سنگین باران و برف + برفی + بارش دوره‌ای سنگین باران + بارش سبک باران و برف و رعد + بارش سنگین برف و رعد + تنظیم مکان + به‌کار بردن GPS و خدمات مکان‌یابی برای تعیین مکان به‌صورت خودکار + مدیریت شده توسط افزونه + مکان برای این ارائه‌دهنده توسط برنامه افزونه مدیریت می‌شود + مکان + اشکال‌زدایی + ابزارها + به‌کار بردن درجه فارنهایت و مایل بر ساعت + واحدهای امپریال + حالت فشرده + ثبت یک نمونه برای تحلیل مصرف حافظه. برنامه تا پایان این فرآیند متوقف می‌شود. + در حال گرفتن نگارش پشتیبان… + پاکسازی پایگاه داده + پاک‌کردن ورودی‌های خراب و به‌کار نرفته از پایگاه داده برنامه + نصب دوباره بسته نمادها + پاک کردن و بازسازی حافظه نهان بسته‌های نماد + نمادها + کارت‌ها + سفارشی‌سازی ظاهر کارت‌ها + شعاع گوشه + گرد + به عنوان %1$s وارد شده‌اید + خروج + مدیریت حساب‌ها و خدمات متصل‌شده + پنج‌ضلعی + پس‌زمینه + کم‌رنگ کردن پس‌زمینه + شما هنوز وارد نشده‌اید + به‌کارگیری اثر محو بر روی پس‌زمینه + در این دستگاه پشتیبانی نمی‌شود + شعاع محو + انتخاب تصویر زمینه + نمایش نشان برای برنامه‌های معلق + برنامه‌های معلق + نشان‌های ابری + نشان‌های میانبر + نمایش نشان برای میانبرها + نشان‌های افزونه + نشان دادن اینکه یک نتیجه جستجو توسط کدام افزونه ایجاد شده است + ورود به نکست‌کلود + ورود برای جستجو در سرور نکست‌کلود خود + اشکال‌زدایی + ابزارهای عیب‌یابی + ابزارک‌ها + نمایش برچسب‌ها + نمایش نام برنامه زیر نماد + پشتیبان‌گیری و بازیابی + صادرات و واردات داده‌های راه‌انداز + پشتیبان‌گیری + بازیابی + نمایش آلارم‌هایی که در ۸ ساعت آینده به صدا درمی‌آیند + گزارش‌های خطا و خرابی + گزارش‌ها + مشاهده و صدور گزارش‌های برنامه + پنهان کردن وظیفه‌های انجام‌شده + سفارشی کردن ظاهر نوار جستجو + ساخته شده %1$s در %2$s با %3$s. + کشیدن به راست + یک برچسب بدون نام نمی‌توانید داشته باشید. اگر ادامه دهید، برچسب پاک خواهد شد. + نام برچسب + جستجو / کشوی برنامه‌ها + کشیدن به پایین + کشیدن به چپ + نمایش گزینه‌های نیرو + بازکردن برنامه‌های اخیر + شما یک حرکت \"%1$s\" انجام داده‌اید. این حرکت هم برای اجرای کنش \"%2$s\" تنظیم شده است. با این حال، کنش دلخواه به دلیل زیر قابل اجرا نبود: + ترتیب یافته‌های جستجو + خدمات دسترسی برنامه راه‌انداز باید روشن باشد تا این کار انجام شود. + بازپیوندی + ناسازگاری + حل کردن + آخرین نگارش نگه‌داری شده: + حرکات + حرکات و اقدامات حرکتی + کشیدن به پایین + کشیدن به چپ + کشیدن به راست + آیا واقعاً می‌خواهید طرح رنگی %1$s را پاک کنید؟ + طرح رنگی جدید + به‌کار بردن پیش‌گزیده سامانه + بازکردن جستجو + اجرای برنامه + بازکردن بخش آگاه‌سازی + جستجوی پرونده + ارائه‌دهنده آب و هوا + سفارشی کردن پالایش پیش‌گزیده برای جستجوها + تنظیمات یکپارچگی آب و هوا + هیچ تقویمی پیدا نشد + انتخاب ابزارک + پارکینگ + پمپ بنزین + پالایش کنونی به شیوه پیش‌گزیده یافته‌های برخط را روشن می‌کند. جستجوی شما شاید ناخواسته به خدمات وب خارجی فرستاده شوند. به دلایل حریم خصوصی، این کار توصیه نمی‌شود. + جستجوی اوپن‌استریت‌مپ برای فروشگاه‌ها و مکان‌های دیگر در ناحیه بومی + شعاع جستجوی بزرگ می‌تواند به شکل قابل توجهی سرعت جستجو را کاهش دهد. + بازکردن برنامه تقویم + برنامه‌های کاری متوقف شده‌اند. + بازگشایی + متوقف کردن برنامه‌های کاری + فضای شخصی قفل شده است. + غیرقابل دسترس + بازگردانی به پیش‌گزیده + اعمال پوسته + پرونده انتخاب‌شده قابل خواندن نیست. لطفاً مطمئن شوید که یک پرونده پوسته معتبر (*.kvtheme) انتخاب کرده‌اید و پرونده خراب نیست. + روشن‌کردن پلاگین + واحد طول + متریک + این افزونه به‌درستی کار نمی‌کند + در آغاز باید این افزونه را تنظیم کنید + این مورد دیگر وجود ندارد. + جدا کردن برنامه‌های پروپرونده کاری + نمایش برنامه‌های پروپرونده کاری در یک برگه جدا + برجسته + ساده + فست‌فود + رستوران + سوپرمارکت + مدرسه + کافه + هتل + داروخانه + بیمارستان + دفتر پست + جواهرفروشی + صباغی + بانک + خودپرداز + کیوسک + زمین بسکتبال + زمین تنیس + یادبود + ابزارک و یافته‌های جستجوی تقویم + یافته‌های جستجو + برچسب‌ها + شبکه برنامه‌ها و نتایج جستجو + تقویم + کاباره + + در %1$s دقیقه کامل میشود + در %1$s دقیقه کامل میشود + + هیچ ارائه‌دهنده آب‌وهوایی انتخاب نشده یا ارائه‌دهنده انتخاب‌شده در دسترس نیست + عنوان قبلی + جنوب جنوب غربی + جنوب غربی + غرب جنوب غربی + غرب + طوفان برف + رعد و برق + بارش سنگین برف + بارش برف و رعد + بارش دوره‌ای سبک باران و برف + دسترسی موقعیت را برای جستجوی مکان‌های نزدیک بدهید. + بسته نماد + میانبرهای برنامه + ماشین‌حساب + برچسب‌های فشرده + مخفی کردن برچسب‌ها یا نمادها برای کاهش فضای اشغال‌شده توسط برچسب‌ها + صفحه خانگی + پشتیبان‌گیری تکمیل شد. + پیش‌گزیده + پشتیبان بازیابی شد. + انتخاب نماد + کار + همه بسته‌های نماد + شخصی + ساخت میانبر + تعداد ردیف‌ها + تماس + انجام شده + کنش‌های بیشتر + شمال غربی + جایگاه نوار جستجو + پاک‌سازی + شمال شمال غربی + از ویکی‌پدیا + نوار جستجوی ثابت + نوار جستجو را بیرون از دیدره پیمایش نکنید + چرخش ثابت صفحه + قفل کردن چرخش صفحه در حالت عمودی + نگه‌داشتن + دکمه/حرکت خانه + کاری نکن + هماهنگ‌سازی محتوای این ابزارک با یک پرونده خارجی + رد پیوند + هم‌رسانی ماشین + فروشگاه تلفن همراه + مرکز خرید + فروشگاه مبلمان + فروشگاه مشروبات الکلی + گل‌فروشی + + %1$s نشانی + %1$s نشانی + + منبع برای رنگ‌های پویا + سامانه + تصویر زمینه + مدیریت افزونه‌های نصب‌شده + هیچ افزونه‌ای نصب نشده است + دنبال کردن سامانه + نمادهای پوسته‌دار + رنگ‌آمیزی نمادها با طرح رنگی برنامه + نوارهای سامانه + نمادهای نوار وضعیت + اون‌کلود (Owncloud) + ورود به Owncloud + نکست‌کلود (Nextcloud) + فشرده + نمایش ثانیه‌ها + تاریخ + جستجو + جستجو، برچسب‌ها، موارد مخفی + موارد دلخواه + نمایش موارد دلخواه و مورد استفاده بالا بالای شبکه برنامه‌ها + پرونده‌ها + کاربرد: ۱.۵ کیلوگرم یا ۴ سانتی‌متر >> اینچ + تبدیل ارز + ویکی‌پدیا + محاسبه عبارات ریاضی + تبدیل واحد + نکست‌کلود + گزینه ویرایش + هنوز حساب نکست‌کلود را متصل نکرده‌اید + متغیر + کنیسه + + %1$d مورد انتخاب‌شده + %1$d مورد انتخاب‌شده + + خصوصی + موارد دلخواه + + %1$d مورد پاک‌شده. + %1$d مورد پاک‌شده. + + پارک + واحدهای پشتیبانی شده + فرمت زمان + 24 ساعته + 12 ساعته + پیش‌گزیده سامانه + به‌کارگیری رنگ‌های پوسته + پر کردن ارتفاع صفحه + تراز + بالا + میانه + پایین + ناجیه پویا + نمایش تاریخ کنونی + نوار ابزار + نمایش اولین ردیف موارد دلخواه + وارد کردن یک پشتیبان ایجاد شده قبلی + صادرات تنظیمات و داده‌های راه‌انداز + به شیوه دوره‌ای نرخ ارزها را برای تبدیل واحد پولی دریافت کنید + جستجوی دانشنامه آزاد + وبگاه‌ها + مکان‌ها + شعاع جستجو + اگر متن جستجو شده بک آدرس وب باشد، پیش‌نمایشی از وبگاه نمایش دهید + جستجوی منطقه محلی برای فروشگاه‌ها و سایر مکان‌ها + فایل‌های محلی + گوگل درایو + ورود برای جستجوی گوگل درایو + تقویم‌ها + پنهان کردن رویدادهای کل-روز + رنگ + درباره ساخت + داده‌های بیشتر درباره این نگارش از برنامه + سبک + فروشگاه حیوانات + خرید + سالن کنسرت + ورزشگاه + زمین گلف + کازینو + فروشگاه تخفیف + نمایش دکمه آشکارسازی + نمایش دکمه‌ای برای آشکار کردن یافته‌های جستجوی پنهان + برچسب‌ها + نمایش موارد مورد استفاده بالا در موارد دلخواه + نمایش دکمه‌ای برای بازچینی موارد دلخواه + مدیریت برچسب‌ها و موارد برچسب‌گذاری شده + نشانی وبگاه ویکی‌پدیا + تغییر ترتیب موارد سنجاق‌شده + بیشتر به‌کار گرفته شده + آب و هوا + هدایت رسانه + اتصال حساب + هنوز حساب اون‌کلود را متصل نکرده‌اید + هنوز حساب گوگل را متصل نکرده‌اید + در حال شارژ + هیچ موردی با این برچسب نیست + موارد سنجاق شده و بیشتر استفاده شده اینجا نمایش داده خواهند شد + پیام + رایانامه + تنظیم هشدار + نمایش در موارد دلخواه + برچسب‌ها + کنش‌های سریع + پیکربندی کنش‌های سریع و میانبرهای جستجو + ویرایش برچسب + برچسبی با این نام از پیش دارید. + داده‌های بیشتر + آزمایشی + برچسب جدید + برچسبی با این نام از پیش دارید. اگر ادامه دهید، دو برچسب یکی خواهند شد. + بازکردن تنظیمات سریع + از پایین به بالا + دوبار زدن + این کار نیازمند روشن بودن خدمات دسترسی برنامه راه‌انداز است. + جستجوی مکان‌ها + پالایش پیش‌گزیده + نمایش نوار پالایش + سفارشی کردن نوار پالایش + مشبک + نمایش پالایشها + مخفی کردن پالایشها + یافته‌های برخط + برنامه‌ها + سرویس بهداشتی + پلیس + دندانپزشک + کتابخانه + مغازه بستنی + تئاتر + سینما + باشگاه شبانه + کلینیک + کتاب‌فروشی + سفارشی‌سازی موارد موجود در نوار پالایش + اوپن‌استریت‌مپ (OpenStreetMap) + بار + پزشکان + زمین فوتبال + نانوایی + عینک‌فروشی + هنرهای رزمی + ژیمناستیک + زمین هاکی روی یخ + زمین هندبال + زمین والیبال + اسکی + زمین کریکت + زمین بیسبال + ایستگاه آتش‌نشانی + دادگاه + شهرداری + ساختمان دولتی + ابزارک پاک شد + بارش باران و برف + بارش سنگین باران + برف سبک و رعد + بارش باران سبک + بارش سبک باران و برف + بارش سنگین باران و برف و رعد + بارش باران + بارش برف + بارش سنگین باران و رعد + باد + ناشناس + داده‌های آب و هوایی در دسترس نیست. + باران‌های سبک متناوب + برف سبک + بارش‌های تند برف و باران همراه با رعد و برق + بارش‌های سبک برف + بارش‌های سبک برف و باران همراه با رعد و برق + بارش‌های برف و باران همراه با رعد و برق + بارش سنگین باران و برف + بارش دوره‌ای باران سبک و رعد + جستجوی وب + افزودن به مخاطبین + مشاهده وبگاه + برنامه‌ریزی رویداد + چه سبک کنشی می‌خواهید ایجاد کنید؟ + جستجو در یک وبگاه + جستجو در یک برنامه + intent سفارشی + اقدام سریع جدید + نگارخانه + رستوران ژاپنی + رستوران رامن + رستوران ناهار + شورشویی + ایستگاه شارژ + اجاره موتورسیکلت + دسترسی به مخاطبین را برای جستجوی مخاطبین خود اعطا کنید. + متئو نروژ + برای جستجوی میانبرهای برنامه، %1$s را به‌عنوان برنامه پیش‌گزیده خانگی تنظیم کنید. + برای ایجاد میانبرها، %1$s را به‌عنوان برنامه پیش‌گزیده خانگی تنظیم کنید. + پوسته + دسترسی به کل حافظه برای جستجو در پرونده‌های محلی لازم است. + جدا شده \ No newline at end of file diff --git a/core/i18n/src/main/res/values-fa/units.xml b/core/i18n/src/main/res/values-fa/units.xml index a6b3daec..1212aa95 100644 --- a/core/i18n/src/main/res/values-fa/units.xml +++ b/core/i18n/src/main/res/values-fa/units.xml @@ -1,2 +1,47 @@ - \ No newline at end of file + + گیبی‌بایت + تیبی‌بایت + طول + جرم + سرعت + حجم + مساحت + ارز + داده + نرخ بیت + فشار + انرژی + کیلومتر + سانتی‌متر + میلی‌متر + اینچ + پا + یارد + مایل + هکتار + جریب + ثانیه + دقیقه + روز + میلی‌ثانیه + ساعت + سال + کیلوبایت + گره + کیلوگرم + گرم + تن متریک + پوند + اونس + استون + کلوین + تن کوتاه + متر + مگابایت + گیگابایت + کیبی‌بایت + میبی‌بایت + بیت + تن بلند + \ No newline at end of file diff --git a/core/i18n/src/main/res/values-hu/strings.xml b/core/i18n/src/main/res/values-hu/strings.xml index 6bd3805f..6a666421 100644 --- a/core/i18n/src/main/res/values-hu/strings.xml +++ b/core/i18n/src/main/res/values-hu/strings.xml @@ -6,7 +6,7 @@ Kitűzés a kedvencekhez Kitűzés megszüntetése Vissza - Alkalmazás információ + Információ az alkalmazásról Futtatás Testreszabás Elrejtés @@ -35,15 +35,15 @@ Méret: %1$s Hely: %1$s Típus: %1$s - Alkalmazásnév: %1$s + Alkalmazás neve: %1$s Verzió: %1$s - Csomagnév: %1$s - Legrégibb SDK verzió: %1$s + Csomag neve: %1$s + Minimum SDK-verzió: %1$s Tulajdonos: %1$s Hely: %1$s Könyvtár Android csomagfájl - Forráskódfájl + Forráskód-fájl Dokumentum Táblázat Zenefájl @@ -76,14 +76,14 @@ Eltávolítás Kedvencek módosítása https://en.wikipedia.org - Archív fájl - Prezentáció + Archivált fájl + Bemutató Űrlap %1$s biztonsági mentés A(z) %1$s könyvtár és annak teljes tartalma véglegesen törlődni fog. Folytatja? YouTube https://www.youtube.com/results\?search_query=${1} - Play Áruház + Google Play Időjárás Itt nincsenek Easter Eggek, hacsak nem hozott magával. Bezárás @@ -165,8 +165,8 @@ Enyhe eső záporok és mennydörgés Enyhe havazás Heves havas eső záporok és mennydörgés - %1$s színséma - Töltés animáció + %1$s téma + Animáció töltéskor Méretek: %1$s A(z) „${1}” helyőrző hiányzik ebből az webcímből application/x-www-form-urlencoded @@ -181,11 +181,11 @@ A helyszín nem található. Állítsa be a(z) %1$s alkalmazást alapértelmezett indítóként a telepített alkalmazások kereséséhez. Alapértelmezett - Ikonok kényszerítése a színsémához + Ikonok kényszerítése a témához A médialejátszás vezérléséhez értesítési hozzáférés szükséges A naptárban való kereséshez szükséges a naptárhoz való hozzáférés engedélyezése. Pillanatfelvétel készítése a memóriahasználat elemzéséhez. Az alkalmazás lefagy, amíg ez a folyamat be nem fejeződik. - Hibás- és nem használt bejegyzések eltávolítása az indító adatbázisból + Hibás- és nem használt bejegyzések eltávolítása az indító adatbázisából %1$d bejegyzés el lett távolítva. %1$d bejegyzés el lett távolítva. @@ -194,7 +194,7 @@ Adjon engedélyt a naptárnak a közelgő találkozók és események megjelenítéséhez. Heves hó záporok Fekete-fehér - Az alkalmazás színsémájának alkalmazása az összes ikonra, beleértve a nem támogatottakat is (nem ajánlott) + Az alkalmazás színösszeállításának használata az összes ikonra, beleértve a nem támogatottakat is (nem ajánlott) Felhős Jégeső Hó záporok és mennydörgés @@ -230,13 +230,13 @@ Heves eső záporok Verzió Rendszersávok - Színséma + Téma Állapotsor ikonjai Hivatkozások Navigációs sáv elrejtése Ikoncsomagok újratelepítése Az ikoncsomag gyorsítótárának törlése és újraépítése - Színes ikonok az alkalmazás színsémájának megfelelően + Ikonok színezése az alkalmazás színösszeállításával Weboldal megnyitása Alkalmazás- és megállapodás információk A megjelenés testreszabása @@ -290,12 +290,12 @@ Telepített kiterjesztések kezelése Nincsenek telepített bővítmények Kövesse a rendszer beállításait - Színsémának megfelelő ikonok + Témának megfelelő ikonok Ikoncsomag Nincs telepítve ikoncsomag Animációk - Buborék animáció lejátszása a készülék töltésekor - Navigációs sáv ikonok + Buborék animáció lejátszása az eszköz töltésekor + Navigációs sáv ikonjai Automatikus Világos Sötét @@ -311,12 +311,12 @@ A naptárban való kereséshez, naptár engedélyre van szükség Heves eső záporok és mennydörgés Heves havas eső és mennydörgés - A fényképek, média és dokumentumok kereséséhez tárhely engedélyre van szükség. - Állítsa be a(z) %1$s alkalmazást alapértelmezett indító alkalmazásként a parancsikonok létrehozásához. - A kiválasztott fájlt nem lehetett beolvasni. Győződjön meg róla, hogy érvényes színsémát (*.kvtheme) választott ki és hogy a fájl nem sérült-e. - Kinyit gomb megjelenítése + A fényképek, média és dokumentumok kereséséhez tárhely engedélyre van szükség ezen az eszközön. + Állítsa be a(z) %1$s alkalmazást alapértelmezett indítóként a parancsikonok létrehozásához. + A kiválasztott fájlt nem lehetett beolvasni. Győződjön meg róla, hogy érvényes témát (*.kvtheme) választott ki és hogy a fájl nem sérült-e. + Kibontásgomb megjelenítése Nincsenek elemek ilyen címkével - Keresés / alkalmazás tár + Keresés / alkalmazásfiók Médiaalkalmazások Használat: 1.5 kg, vagy 4 cm >> in Ugrás a kiemelt keresési találatra vagy gyors műveletre, ha megérinti az „Enter” gombot @@ -329,7 +329,7 @@ Időjárás-előrejelzés szolgáltató Beállítás időjárás-előrejelzés szolgáltatónak Alulra - Az akkumulátor aktuális töltöttségi szintjének megjelenítése, amikor az akkumulátor lemerült vagy töltődik + Az akkumulátor jelenlegi töltöttségi szintjének megjelenítése, amikor az akkumulátor lemerült vagy töltődik Azon riasztások megjelenítése, amelyek a következő 8 órán belül megszólalnak Naplók Alkalmazásnaplók megtekintése és exportálása @@ -363,7 +363,7 @@ ⚡︎ Töltés ⚡︎ E-mail Riasztás beállítása - Milyen akciót szeretne létrehozni? + Milyen műveletet szeretne létrehozni? További információ Jobbra húzás Futó alkalmazások megjelenítése @@ -372,7 +372,7 @@ Időjárás Parancsikon jelvények Az alkalmazás nevének megjelenítése az ikon alatt - Az óra stílusának és összetevőinek konfigurálása + Az óra stílusának és összetevőinek beállítása Használjon elmosódást a háttérképen Nem támogatott ezen az eszközön Háttérkép kiválasztása @@ -384,10 +384,10 @@ Modulok Médiavezérlők megjelenítése, ha aktív médiamunkamenet van Fájlkeresés - Hiba- és összeomlás-jelentések + Hiba- és összeomlási jelentések Változtatható Valutaváltó - A billentyűzet automatikus megjelenítése az alkalmazásfiók kinyitásakor + A billentyűzet automatikus megjelenítése az alkalmazásfiók megnyitásakor Szinkronizálja a modul tartalmát egy külső fájllal Jelenleg időjárás-előrejelzés szolgáltatóként van beállítva Weboldalak @@ -416,7 +416,7 @@ Név Rögzített keresősáv Címkék - A visszaállítani kívánt biztonsági mentés %1$s-kor lett létrehozva, a(z) %2$s készüléken, ezzel: %3$s. + Létrehozva ekkor: %1$s, a(z) %2$s nevű eszközön, ezzel: %3$s. A webcím-sablon, az internetes keresés webcímének létrehozásához használatos. Használja a(z) „${1}” kulcsot a tényleges keresési kifejezés helyőrzőjeként, pl. https://google.com/search?q=${1}. %1$s perc múlva feltöltődik @@ -424,11 +424,11 @@ Esemény ütemezése Adja meg a weboldal címét: - Lentről-fölfelé + Lentről-felfelé Ehhez a címkéhez nincsenek elemek hozzárendelve. Ha folytatja, a címke törlődik. Elemek kiválasztása: Egy „%1$s” gesztust hajtott végre. Ez a gesztus jelenleg egy „%2$s” művelet kiváltására van beállítva. Az akciót azonban a következő okból nem lehetett végrehajtani: - Föntről-lefelé + Fentről-lefelé Lefelé húzás Kikapcsolási menü megjelenítése A keresési eredmények sorrendje @@ -436,8 +436,8 @@ Keresés egy weboldalon Keresés egy alkalmazásban Egyéni intent - Új gyors művelet - Gyors művelet szerkesztése + Új gyorsművelet + Gyorsművelet szerkesztése Az adott weboldal nem importálható automatikusan webes keresésként. A következő lépésben megpróbálhat egy másik webhelyet, vagy manuálisan megadhatja a szükséges adatokat. Alkalmazás Webcím sablon @@ -449,7 +449,7 @@ F-Droid tároló Kényszerített alakzat Névjegyek - Szerkesztés gomb + Szerkesztésgomb Egy gomb megjelenítése a kedvencek átrendezéséhez Rács és ikonok Még nem kapcsolódik egyetlen Google fiókhoz sem @@ -464,7 +464,7 @@ Szegély nélküli Munka A-Z - Az időjárás integráció beállításai + Az időjárás-integráció beállításai Nem található naptár Médiavezérlő-integráció beállításai Hivatkozás a fájlra @@ -474,15 +474,15 @@ Ez a biztonsági mentés a(z) %1$s egy másik verziójával készült. Előfordulhat, hogy egyes adatok nem lesznek helyesen visszaállítva. Sorok száma Gyorsműveletek - Gyorsműveletek és keresési parancsikonok konfigurálása + Gyorsműveletek és keresési parancsikonok beállítása Újraválasztás Ütközés Ütközések megoldása A hivatkozott fájl nem üres, és a tartalma nem egyezik a jegyzet utolsó mentett verziójával. Melyik változatot szeretné megtartani? - Háttérkép homályosítása - Sötét színsémák esetén sötétítse a háttérképet + Háttérkép sötétítése + Sötét téma esetén sötétítse a háttérképet Háttérkép elmosása - Elmosási sugár + Elmosás sugara A felfüggesztett alkalmazások jelvényeinek megjelenítése Felfüggesztett alkalmazások Felhő jelvények @@ -509,7 +509,7 @@ Felülre Dinamikus zóna Dátum - Az aktuális dátum megjelenítése + A jelenlegi dátum megjelenítése Dokk A kitűzött elemek első sorának megjelenítése Média @@ -521,7 +521,7 @@ A beállítások és az indító alkalmazás adatainak exportálása Visszaállítás Egy korábban létrehozott biztonsági mentés importálása - Összeomlás-jelentő + Összeomlásjelentő Kedvencek Kitűzött és gyakran használt elemek megjelenítése az alkalmazásrács felett Fájlok @@ -531,12 +531,12 @@ Keresés a telepített alkalmazások között Számológép Matematikai kifejezések kiértékelése - Mértékegység váltó + Mértékegységváltó Rendszeresen töltse le az árfolyamokat a valuták átváltásához Wikipédia Keresés a Wikipédián Helyi fájlok - Dokumentumok, fényképek és egyéb, ezen az eszközön tárolt fájlok keresése + Dokumentumok, fényképek és egyéb, ezen az eszközön tárolt fájlok keresése ezen az eszközön Keresés a(z) %1$s fájljai között a Google Drive-on Nextcloud Owncloud @@ -567,16 +567,16 @@ Modul beállítása Hivatkozik a következő fájlra: %1$s Utolsó mentett verzió: - Aktuális fájl tartalma: + A jelenlegi fájl tartalma: Hiba a jegyzet olvasásakor Hiba a jegyzet mentésekor A jegyzetet nem lehetett a kapcsolódó fájlba írni. Lehetséges, hogy áthelyezték vagy törölték. A másolat el lett mentve az indító alkalmazás belső tárolójába. - Biztosan törölni szeretné a(z) %1$s színsémát? - Új színséma + Biztosan törölni szeretné a(z) %1$s nevű színösszeállítást? + Új színösszeállítás Egyéni Paletta Alapértelmezett visszaállítása - Színséma alkalmazása + Téma alkalmazása Nem érhető el Címke szerkesztése Egy ilyen nevű címke már létezik. @@ -584,9 +584,9 @@ Lefelé húzás Keresősáv pozíciója Hosszú érintés - Kezdőlap-gomb/-gesztus + Kezdőoldalgomb/-gesztus Ne csináljon semmit - Értesítési tár megnyitása + Értesítési fiók megnyitása Képernyő kikapcsolása Gyorsbeállítások megnyitása A művelet végrehajtásához engedélyezni kell a Kvaesitso kisegítő lehetőségek szolgáltatását. @@ -608,8 +608,8 @@ Kategorizálatlan helyszínek elrejtése Csak jól meghatározott kategóriák, például kávézók, vagy éttermek eredményeit jeleníti meg Térkép - Térkép színsémája - Az indító színsémájának alkalmazása a térképre + Térkép témája + Az indító színösszeállításának alkalmazása a térképre Csempekiszolgáló webcíme Hívás Nyitva @@ -622,7 +622,7 @@ Nyitva 24/7 Ez az elem már nem létezik. 7-szegmenses - Színséma használata + Téma színének használata Lap elkülönítés a munkaprofil-alkalmazásokhoz Külön lapon jeleníti meg a munkaprofil-alkalmazásokat Másodperc megjelenítése @@ -634,7 +634,7 @@ Nincs óra Körvonalazott Modul kiválasztása - Modul testreszabása + Egyéni modul Átméretezés Megosztás Internetes keresési találat @@ -644,7 +644,7 @@ Gyors szűrők megjelenítése a billentyűzet felett Alapértelmezett szűrő A keresések alapértelmezett szűrőjének testreszabása - Az aktuális szűrő alapértelmezés szerint lehetővé teszi az internetes keresési találatokat. A keresési lekérdezések véletlenül külső webes szolgáltatásokra kerülhetnek. Adatvédelmi okokból ez nem ajánlott. + A jelenlegi szűrő alapértelmezés szerint lehetővé teszi az internetes keresési találatokat. A keresési lekérdezések véletlenül külső webes szolgáltatásokra kerülhetnek. Adatvédelmi okokból ez nem ajánlott. Adja meg, hogy mely elemek szerepeljenek a szűrősávon Szűrősáv testreszabása Bővítmény által kezelt @@ -779,7 +779,7 @@ Nincs időjárás-szolgáltató kiválasztva vagy a kiválasztott szolgáltató nem érhető el Privát Kész - További lehetőségek + További műveletek Kiürítés Szűrők megjelenítése Szűrők elrejtése @@ -795,7 +795,7 @@ Naptár A munka-alkalmazások szünetelnek. Szüneteltetés feloldása - Munka-alkalmazások szüneteltetése + Munkaalkalmazások szüneteltetése Feloldás Privát szféra lezárása Privát szféra lezárva. @@ -818,4 +818,14 @@ Páratartalom Egy címke nem létezhet név nélkül. Ha folytatja, a címke törlődik. Modul törölve + Rendszer alapértelmezett + Időformátum + 24 órás + 12 órás + + 1 perc múlva indúl + %1$d perc múlva indúl + + most + Naptárak keresése ezen az eszközön \ No newline at end of file diff --git a/core/i18n/src/main/res/values-it/strings.xml b/core/i18n/src/main/res/values-it/strings.xml index 9f7ca179..112faad0 100644 --- a/core/i18n/src/main/res/values-it/strings.xml +++ b/core/i18n/src/main/res/values-it/strings.xml @@ -827,4 +827,15 @@ Vento Widget rimosso Un tag non può esistere senza un nome. Se continui, il tag verrà eliminato. + Formato ora + 24 ore + 12 ore + Predefinito del sistema + + tra un minuto + tra %1$d minuti + tra %1$d minuti + + adesso + Cerca calendari su questo dispositivo \ No newline at end of file diff --git a/core/i18n/src/main/res/values-nl/strings.xml b/core/i18n/src/main/res/values-nl/strings.xml index c1b61476..dac8dfc1 100644 --- a/core/i18n/src/main/res/values-nl/strings.xml +++ b/core/i18n/src/main/res/values-nl/strings.xml @@ -818,4 +818,8 @@ Neerslag Een label kan niet bestaan zonder een naam. Als je doorgaat, wordt het label verwijderd. Widget verwijderd + Tijdformaat + 24-uur + 12-uur + Systeemstandaard \ No newline at end of file diff --git a/core/i18n/src/main/res/values-pl/units.xml b/core/i18n/src/main/res/values-pl/units.xml index 6906ff26..07108dd8 100644 --- a/core/i18n/src/main/res/values-pl/units.xml +++ b/core/i18n/src/main/res/values-pl/units.xml @@ -378,4 +378,15 @@ Decymetrów dm + Długość + Prędkość + Głośność + Obszar + Waluta + Dane + Bitrate + Energia + Częstotliwość + Temperatura + Czas \ No newline at end of file diff --git a/core/i18n/src/main/res/values-th/strings.xml b/core/i18n/src/main/res/values-th/strings.xml index c9634ba2..25eabb09 100644 --- a/core/i18n/src/main/res/values-th/strings.xml +++ b/core/i18n/src/main/res/values-th/strings.xml @@ -494,19 +494,19 @@ รายชื่อผู้ติดต่อ หน้าจอหลัก คุณต้องอนุญาตให้แอปเข้าถึงการแจ้งเตือนเพื่อการแสดงป้ายการแจ้งเตือน - ค้นหารายชื่อผู้ติดต่อบนอุปกรณ์เครื่องนี้ + ค้นหารายชื่อผู้ติดต่อบนอุปกรณ์นี้ นาฬิกา แถบค้นหา ภาพพื้นหลัง แถบระบบ คุณต้องอนุญาตให้แอปเข้าถึงปฏิทินเพื่อค้นหาในปฏิทินของคุณ ค้นหานัดหมายและกิจกรรมที่กำลังมาถึง สภาพอากาศ - คุณต้องอนุญาตให้แอปเข้าถึงพื้นที่จัดเก็บข้อมูลเพื่อค้นหารูปภาพ สื่อและเอกสารบนอุปกรณ์เครื่องนี้ + คุณต้องอนุญาตให้แอปเข้าถึงพื้นที่จัดเก็บข้อมูลเพื่อค้นหารูปภาพ สื่อและเอกสารบนอุปกรณ์นี้ คำนวณและดำเนินการทางคณิตศาสตร์ กำหนดให้แอป %1$s เป็นตัวใช้งานเริ่มต้นเพื่อสร้างทางลัด ดาวน์โหลดอัตราแรกเปลี่ยนเพื่อแปลงสกุลเงินเป็นระยะๆ แสดงตัวอย่างของเว็บไซต์ถ้าคำค้นหาเป็นลิงก์ ตัวควบคุมสื่อ ธีม - ค้นหาเอกสาร รูปภาพและไฟล์อื่นๆที่เก็บบนอุปกรณ์เครื่องนี้ + ค้นหาเอกสาร รูปภาพและไฟล์อื่นๆที่เก็บบนอุปกรณ์นี้ สว่าง คุณยังไม่ได้เชื่อมต่อกับบัญชี Owncloud ขาว-ดำ @@ -808,4 +808,14 @@ ความชื้น ลม ไม่สามารถตั้งแท็กที่ไม่มีชื่อได้ หากคุณดำเนินการต่อ แท็กจะถูกลบ + รูปแบบเวลา + 24 ชั่วโมง + ลบวิดเจ็ตแล้ว + 12 ชั่วโมง + ค่าเริ่มต้นของระบบ + + ในอีก %1$d นาที + + ตอนนี้ + ค้นหาปฏิทินบนอุปกรณ์นี้ \ No newline at end of file diff --git a/core/i18n/src/main/res/values-tr/strings.xml b/core/i18n/src/main/res/values-tr/strings.xml index c4e89289..176f8a66 100644 --- a/core/i18n/src/main/res/values-tr/strings.xml +++ b/core/i18n/src/main/res/values-tr/strings.xml @@ -798,4 +798,15 @@ Takvimi aç Ögeleri düzenlemek için basılı tutun ve sürükleyin + Karla karışık yağmur ile gök gürültüsü + Parçalı bulutlu + Şiddetli kar ve gök gürültüsü + Yağmur ve gök gürültüsü + Hafif karla karışık yağmur + Widget kaldırıldı + Sulu kar + Fırtına + Yoğun karla karışık yağmur ve gök gürültüsü + Sağanak yağmur + Hafif sulu kar ve gök gürültüsü \ No newline at end of file diff --git a/core/i18n/src/main/res/values-zh-rTW/strings.xml b/core/i18n/src/main/res/values-zh-rTW/strings.xml index 982036ff..364ddad3 100644 --- a/core/i18n/src/main/res/values-zh-rTW/strings.xml +++ b/core/i18n/src/main/res/values-zh-rTW/strings.xml @@ -107,9 +107,9 @@ 捷徑 %1$s 將被永久移除。繼續? YouTube https://www.youtube.com/results\?search_query=${1} - Google Play商店 + Google Play https://play.google.com/store/search\?q=${1} - 此 URL 中缺少預留位置’${1}‘ + 此 URL 中缺少預留位置『${1}』 替換圖示 刪除圖示 自訂圖示 @@ -145,7 +145,7 @@ 被釘選的標籤將出現在這裡 建立標籤… Nextcloud 伺服器 URL - 伺服器URL禁止為空 + 伺服器 URL 禁止為空 此 URL 不能指向一個有效的 Nextcloud 安裝器 Owncloud 伺服器 URL 此 URL 不能指向一個有效的 Owncloud 安裝器 @@ -376,12 +376,12 @@ 本機檔案 搜尋儲存在此裝置上的文件、照片與其它檔案 Google Drive - 在Google Drive上搜尋 %1$s + 在 Google Drive 上搜尋 %1$s Nextcloud 搜尋%1$s的檔案 Owncloud 使用 Google 登入 - 登入以查詢 Google Drive + 登入以搜尋 Google Drive 日曆 隱藏全天事件 有關此版本應用程式的更多資訊 @@ -478,10 +478,10 @@ 登入 未安裝任何圖示包 - 從過去%1$d天運行的事件 + 從過去 %1$d 天運行的事件 - 已刪除%1$d條目。 + 已刪除 %1$d 個條目。 輸入時啟動 在點擊「前往」後啟動突顯顯示的匹配項或快速操作 @@ -489,7 +489,7 @@ %1$s 分鐘內完成 - 已選擇 %1$d 項目 + 已選擇 %1$d 個項目 啟動應用程式 所有圖示包 @@ -576,7 +576,7 @@ 長度單位 Overpass URL - %1$s 通知 + %1$s 個通知 沒有天氣服務商被選擇或者選擇的服務商不可用 管理已安裝的擴充套件 @@ -748,7 +748,7 @@ 私人空間被鎖定。 暫停工作應用程式 - %1$s電話號碼 + %1$s 個電話號碼 手球場 私人 @@ -782,10 +782,10 @@ 應用程式網格 & 搜尋結果 開啟日曆應用程式 - %1$s電子郵件位址 + %1$s 個電子郵件位址 - %1$s郵政地址 + %1$s 個郵政地址 體操場 紀念碑 @@ -804,6 +804,11 @@ 上一首 下一首 - 已選擇 %1$s 列表 + 已選擇 %1$s 個列表 + 24 小時制 + 12 小時制 + 系統預設 + 時間格式 + 小工具已移除 \ No newline at end of file diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index e234596c..3e9b3d06 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -390,6 +390,8 @@ Notification access is required to control media playback Contact permission is required to search contacts + + Call permission is required to start calls Calendar permission is required to search calendar @@ -555,8 +557,12 @@ Grid Icon size Number of columns + Show app results in a list + Show app icons in list Show labels Show the app name below the icon + Show applications in a list view instead of grid + Show icons in the list view Debug Troubleshooting tools Widgets @@ -567,6 +573,10 @@ Default Compact Show seconds + Time format + 24-hour + 12-hour + System default Use theme color Fill screen height Alignment @@ -604,6 +614,7 @@ Search contacts on this device Calendar Search upcoming appointments and events + Search calendars on this device App shortcuts Search app shortcuts Calculator @@ -663,6 +674,8 @@ Accounts Weather Media control + Call on tap + Directly start calls when tapping phone numbers You haven\'t connected a Nextcloud account yet @@ -1010,4 +1023,10 @@ Humidity Wind Precipitation + now + + in one minute + in %1$d minutes + + departed \ No newline at end of file diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt index f305ba7e..e39230dc 100644 --- a/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt @@ -15,3 +15,15 @@ fun List.randomElementOrNull(): T? { fun List?.ifNullOrEmpty(block: () -> List): List { return if (this.isNullOrEmpty()) block() else this } + +fun List.distinctByEquality(equalityPredicate: (T, T) -> Boolean): List { + if (size < 2) return this + + val ret = mutableListOf() + + for (item in this) { + if (ret.none { equalityPredicate(it, item) }) ret.add(item) + } + + return ret +} diff --git a/core/permissions/src/main/java/de/mm20/launcher2/permissions/PermissionsManager.kt b/core/permissions/src/main/java/de/mm20/launcher2/permissions/PermissionsManager.kt index cf79a5e6..b471ef3c 100644 --- a/core/permissions/src/main/java/de/mm20/launcher2/permissions/PermissionsManager.kt +++ b/core/permissions/src/main/java/de/mm20/launcher2/permissions/PermissionsManager.kt @@ -65,6 +65,7 @@ enum class PermissionGroup { AppShortcuts, Accessibility, ManageProfiles, + Call, } internal class PermissionsManagerImpl( @@ -93,6 +94,9 @@ internal class PermissionsManagerImpl( private val manageProfilesPermissionState = MutableStateFlow( checkPermissionOnce(PermissionGroup.ManageProfiles) ) + private val callPermissionState = MutableStateFlow( + checkPermissionOnce(PermissionGroup.Call) + ) override fun requestPermission(context: AppCompatActivity, permissionGroup: PermissionGroup) { when (permissionGroup) { @@ -167,6 +171,14 @@ internal class PermissionsManagerImpl( CrashReporter.logException(e) } } + + PermissionGroup.Call -> { + ActivityCompat.requestPermissions( + context, + callPermissions, + permissionGroup.ordinal + ) + } } } @@ -209,6 +221,10 @@ internal class PermissionsManagerImpl( PermissionGroup.Accessibility -> { accessibilityPermissionState.value } + + PermissionGroup.Call -> { + callPermissions.all { context.checkPermission(it) } + } } } @@ -222,6 +238,7 @@ internal class PermissionsManagerImpl( PermissionGroup.AppShortcuts -> appShortcutsPermissionState PermissionGroup.Accessibility -> accessibilityPermissionState PermissionGroup.ManageProfiles -> manageProfilesPermissionState + PermissionGroup.Call -> callPermissionState } } @@ -241,6 +258,7 @@ internal class PermissionsManagerImpl( PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted PermissionGroup.Accessibility -> accessibilityPermissionState.value = granted PermissionGroup.ManageProfiles -> manageProfilesPermissionState.value = granted + PermissionGroup.Call -> callPermissionState.value = granted } } @@ -269,5 +287,6 @@ internal class PermissionsManagerImpl( Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE ) + private val callPermissions = arrayOf(Manifest.permission.CALL_PHONE) } } diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt index 8456e566..c8c9a933 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt @@ -1,14 +1,13 @@ package de.mm20.launcher2.preferences import android.content.Context -import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.search.SearchFilters import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class LauncherSettingsData internal constructor( - val schemaVersion: Int = 2, + val schemaVersion: Int = 3, val uiColorScheme: ColorScheme = ColorScheme.System, val uiTheme: ThemeDescriptor = ThemeDescriptor.Default, @@ -34,6 +33,7 @@ data class LauncherSettingsData internal constructor( val clockWidgetCustom: ClockWidgetStyle.Custom = ClockWidgetStyle.Custom(), val clockWidgetColors: ClockWidgetColors = ClockWidgetColors.Auto, val clockWidgetShowSeconds: Boolean = false, + val clockWidgetTimeFormat: TimeFormat = TimeFormat.System, val clockWidgetUseThemeColor: Boolean = false, val clockWidgetAlarmPart: Boolean = true, val clockWidgetBatteryPart: Boolean = true, @@ -53,6 +53,7 @@ data class LauncherSettingsData internal constructor( val fileSearchProviders: Set = setOf("local"), val contactSearchEnabled: Boolean = true, + val contactSearchCallOnTap: Boolean = false, @Deprecated("Use calendarSearchProviders `local` instead") val calendarSearchEnabled: Boolean = true, @@ -81,6 +82,8 @@ data class LauncherSettingsData internal constructor( val gridColumnCount: Int = 5, val gridIconSize: Int = 48, val gridLabels: Boolean = true, + val gridList: Boolean = false, + val gridListIcons: Boolean = true, val searchBarStyle: SearchBarStyle = SearchBarStyle.Transparent, val searchBarColors: SearchBarColors = SearchBarColors.Auto, @@ -408,4 +411,11 @@ enum class KeyboardFilterBarItem { @SerialName("events") Events, @SerialName("tools") Tools, @SerialName("hidden") HiddenResults, +} + +@Serializable +enum class TimeFormat { + @SerialName("system") System, + @SerialName("12h") TwelveHour, + @SerialName("24h") TwentyFourHour } \ No newline at end of file diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsDataSerializer.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsDataSerializer.kt index cdd62f90..e6b88376 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsDataSerializer.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsDataSerializer.kt @@ -2,6 +2,7 @@ package de.mm20.launcher2.preferences import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream @@ -21,6 +22,7 @@ internal object LauncherSettingsDataSerializer : Serializer + get() = dataStore.data.map { it.contactSearchCallOnTap }.distinctUntilChanged() + + fun setCallOnTap(callOnTap: Boolean) { + dataStore.update { it.copy(contactSearchCallOnTap = callOnTap) } + } } \ No newline at end of file diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/ClockWidgetSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/ClockWidgetSettings.kt index 3d473c93..d12094e6 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/ClockWidgetSettings.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/ClockWidgetSettings.kt @@ -5,6 +5,7 @@ import de.mm20.launcher2.preferences.ClockWidgetColors import de.mm20.launcher2.preferences.ClockWidgetStyle import de.mm20.launcher2.preferences.ClockWidgetStyleEnum import de.mm20.launcher2.preferences.LauncherDataStore +import de.mm20.launcher2.preferences.TimeFormat import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -137,6 +138,15 @@ class ClockWidgetSettings internal constructor( } } + val timeFormat + get() = launcherDataStore.data.map { it.clockWidgetTimeFormat } + + fun setTimeFormat(timeFormat: TimeFormat) { + launcherDataStore.update { + it.copy(clockWidgetTimeFormat = timeFormat) + } + } + val useThemeColor get() = launcherDataStore.data.map { it.clockWidgetUseThemeColor } diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/UiSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/UiSettings.kt index 3d6e0162..2eec41c7 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/UiSettings.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/UiSettings.kt @@ -25,6 +25,8 @@ data class GridSettings( val columnCount: Int = 5, val iconSize: Int = 48, val showLabels: Boolean = true, + val showList: Boolean = false, + val showListIcons: Boolean = true, ) class UiSettings internal constructor( @@ -48,6 +50,8 @@ class UiSettings internal constructor( get() = launcherDataStore.data.map { GridSettings( showLabels = it.gridLabels, + showList = it.gridList, + showListIcons = it.gridListIcons, iconSize = it.gridIconSize, columnCount = it.gridColumnCount, ) @@ -71,6 +75,17 @@ class UiSettings internal constructor( } } + fun setGridShowList(showList: Boolean) { + launcherDataStore.update { + it.copy(gridList = showList) + } + } + + fun setGridShowListIcons(showIcons: Boolean) { + launcherDataStore.update { + it.copy(gridListIcons = showIcons) + } + } val cardStyle get() = launcherDataStore.data.map { @@ -342,4 +357,4 @@ class UiSettings internal constructor( it.copy(widgetsEditButton = editButton) } } -} \ No newline at end of file +} diff --git a/core/shared/src/main/java/de/mm20/launcher2/search/location/Departure.kt b/core/shared/src/main/java/de/mm20/launcher2/search/location/Departure.kt index 93b9ae14..d564076f 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/search/location/Departure.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/search/location/Departure.kt @@ -32,16 +32,56 @@ data class Departure( val lineColor: Color?, ) +/** + * Compares two line names. The line naems are split into parts of numbers or letters, then + * each segment is compared. + */ +object LineNameComparator : Comparator { + + // Split line into parts, e.g. "11A" -> ["11", "A"], "S1" -> ["S", "1"], "40-X" -> ["40", "X"] + private val regex = Regex("\\p{L}+|[0-9]+") + + override fun compare(a: String, b: String): Int { + if (a == b) return 0 + val aParts = regex.findAll(a).toList() + val bParts = regex.findAll(b).toList() + + for (i in 0 until minOf(aParts.size, bParts.size)) { + val aPart = aParts[i].value + val bPart = bParts[i].value + + if (aPart == bPart) continue + + val thisPartNumber = aPart.toIntOrNull() + val otherPartNumber = bPart.toIntOrNull() + + if (thisPartNumber != null && otherPartNumber != null) { + // both parts are numbers, compare them as numbers + return thisPartNumber.compareTo(otherPartNumber) + } + + // one part is a number, the other is a string. numbers are automatically less than strings + return aPart.compareTo(bPart) + } + + // 11 < 11A + return aParts.size.compareTo(bParts.size) + } + +} + + +// implicit ordering by ordinal enum class LineType { - Bus, - Tram, Subway, + Tram, + Bus, + Boat, + Monorail, + CableCar, CommuterTrain, RegionalTrain, Train, HighSpeedTrain, - Boat, - Monorail, - CableCar, Airplane, } \ No newline at end of file diff --git a/core/shared/src/main/java/de/mm20/launcher2/search/location/OpeningSchedule.kt b/core/shared/src/main/java/de/mm20/launcher2/search/location/OpeningSchedule.kt index 34f22835..fb897290 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/search/location/OpeningSchedule.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/search/location/OpeningSchedule.kt @@ -27,4 +27,14 @@ sealed interface OpeningSchedule { data object TwentyFourSeven : OpeningSchedule @Serializable data class Hours(@Serializable val openingHours: List) : OpeningSchedule +} + +/** + * Checks whether the [OpeningSchedule] has at least one opening hour. + */ +fun OpeningSchedule.isNotEmpty(): Boolean { + return when (this) { + is OpeningSchedule.Hours -> openingHours.isNotEmpty() + OpeningSchedule.TwentyFourSeven -> true + } } \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt index 76547f34..7fd7f2db 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt @@ -2,9 +2,12 @@ package de.mm20.launcher2.contacts import android.content.ContentUris import android.content.Context +import android.os.Build import android.provider.ContactsContract +import android.telephony.PhoneNumberUtils import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull +import de.mm20.launcher2.ktx.distinctByEquality import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.preferences.search.ContactSearchSettings @@ -136,11 +139,10 @@ internal class ContactRepository( } else -> { - val mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue contactApps += ContactApp( label = dataCursor.getStringOrNull(data3Column) ?: continue, packageName = dataCursor.getStringOrNull(accountTypeColumn) ?: continue, - mimeType = mimeType, + mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue, uri = ContentUris.withAppendedId( ContactsContract.Data.CONTENT_URI, dataCursor.getLongOrNull(idColumn) ?: continue @@ -164,12 +166,25 @@ internal class ContactRepository( } lookupKeyCursor.close() + val mainLocaleISO3 = context.resources.configuration.locales[0].isO3Country + return@withContext AndroidContact( id = id, firstName = firstName, lastName = lastName, displayName = displayName, - phoneNumbers = phoneNumbers.distinct(), + phoneNumbers = phoneNumbers.sortedByDescending { + it.number.count { !PhoneNumberUtils.isReallyDialable(it) } + }.distinctByEquality { a, b -> + if (Build.VERSION.SDK_INT < 31) { + PhoneNumberUtils.compare(context, a.number, b.number) + } else { + PhoneNumberUtils.areSamePhoneNumber(a.number, b.number, mainLocaleISO3) + } + }.mapNotNull { + val formattedNumber = PhoneNumberUtils.formatNumber(it.number, mainLocaleISO3) ?: return@mapNotNull null + it.copy(number = formattedNumber) + }, emailAddresses = emailAddresses.distinct(), postalAddresses = postalAddresses.distinct(), contactApps = contactApps.distinct(), diff --git a/docs/.vitepress/theme/Footer.vue b/docs/.vitepress/theme/Footer.vue index 3bdd8cb2..a0d45035 100644 --- a/docs/.vitepress/theme/Footer.vue +++ b/docs/.vitepress/theme/Footer.vue @@ -1,29 +1,33 @@