Reuse search result item viewmodels

This commit is contained in:
MM20 2023-04-21 13:37:00 +02:00
parent d1482ad112
commit df1aa8c119
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
29 changed files with 658 additions and 481 deletions

View File

@ -0,0 +1,69 @@
package de.mm20.launcher2.ui.launcher.search
import android.util.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
class ListItemViewModelStore() {
private val cache = ViewModelCache()
operator fun <T: ListItemViewModel>get(key: String, modelClass: Class<T>): T {
val cachedInstance = cache[key]
if (cachedInstance != null) {
return cachedInstance as T
}
val newInstance = modelClass.getDeclaredConstructor().newInstance()
cache.put(key, newInstance)
return newInstance
}
inline operator fun <reified T: ListItemViewModel>get(key: String): T {
return get(key, T::class.java)
}
fun clear() {
cache.evictAll()
}
}
private class ViewModelCache: LruCache<String, ListItemViewModel>(500) {
override fun entryRemoved(
evicted: Boolean,
key: String?,
oldValue: ListItemViewModel?,
newValue: ListItemViewModel?
) {
super.entryRemoved(evicted, key, oldValue, newValue)
oldValue?.clear()
}
}
/**
* Knock-off of Android's ViewModel class but not tied to a lifecycle.
* This is useful for view models that are not tied to a lifecycle, e.g. for list items.
*/
open class ListItemViewModel {
val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
fun clear() {
viewModelScope.coroutineContext.cancel()
onCleared()
}
protected open fun onCleared() {
}
}
val LocalListItemViewModelStore = staticCompositionLocalOf { ListItemViewModelStore() }
@Composable
inline fun <reified T: ListItemViewModel>listItemViewModel(key: String): T {
val store = LocalListItemViewModelStore.current
return remember(key) {
store[key]
}
}

View File

@ -1,5 +1,10 @@
package de.mm20.launcher2.ui.launcher.search.apps
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.LauncherApps
import androidx.compose.animation.*
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
@ -20,7 +25,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import de.mm20.launcher2.search.data.LauncherApp
@ -28,6 +36,8 @@ import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.*
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
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.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
@ -43,7 +53,13 @@ fun AppItem(
app: LauncherApp,
onBack: () -> Unit
) {
val viewModel = remember { AppItemVM(app) }
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${app.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(app) {
viewModel.init(app, iconSize.toInt())
}
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
@ -63,7 +79,7 @@ fun AppItem(
style = MaterialTheme.typography.titleMedium
)
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
@ -98,7 +114,7 @@ fun AppItem(
mainAxisSpacing = 12.dp,
crossAxisSpacing = 0.dp
) {
val notifications by viewModel.notifications.collectAsState(initial = emptyList())
val notifications by viewModel.notifications.collectAsState(emptyList())
for (not in notifications) {
val title =
@ -126,7 +142,9 @@ fun AppItem(
viewModel.clearNotification(not)
},
onClick = {
viewModel.openNotification(not)
try {
not.notification.contentIntent?.send()
} catch (e: PendingIntent.CanceledException) {}
}
)
}
@ -173,9 +191,8 @@ fun AppItem(
}
}
}
val badge by viewModel.badge.collectAsState(null)
val iconSize = 84.dp.toPixels().toInt()
val icon by remember(app) { viewModel.getIcon(iconSize) }.collectAsState(null)
val badge by viewModel.badge.collectAsStateWithLifecycle(null)
val icon by viewModel.icon.collectAsStateWithLifecycle()
ShapedLauncherIcon(
size = 84.dp,
modifier = Modifier
@ -213,7 +230,7 @@ fun AppItem(
label = stringResource(R.string.menu_app_info),
icon = Icons.Rounded.Info
) {
viewModel.openAppInfo(context)
app.openAppInfo(context)
})
toolbarActions.add(
@ -240,7 +257,7 @@ fun AppItem(
icon = Icons.Rounded.Share
) {
scope.launch {
viewModel.shareApkFile(context)
app.shareApkFile(context)
}
}
} else {
@ -252,7 +269,10 @@ fun AppItem(
label = stringResource(R.string.menu_share_store_link, storeDetails.label),
icon = Icons.Rounded.Link,
action = {
viewModel.shareStoreLink(context, storeDetails.url)
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, storeDetails.url)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
),
DefaultToolbarAction(
@ -260,7 +280,7 @@ fun AppItem(
icon = Icons.Rounded.Android
) {
scope.launch {
viewModel.shareApkFile(context)
app.shareApkFile(context)
}
}
)
@ -268,13 +288,13 @@ fun AppItem(
}
toolbarActions.add(shareAction)
if (viewModel.canUninstall) {
if (app.canUninstall) {
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_uninstall),
icon = Icons.Rounded.Delete,
) {
viewModel.uninstall(context)
app.uninstall(context)
onBack()
}
)

View File

@ -1,137 +0,0 @@
package de.mm20.launcher2.ui.launcher.search.apps
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.*
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Process
import android.service.notification.StatusBarNotification
import androidx.core.content.FileProvider
import androidx.core.content.getSystemService
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.component.inject
class AppItemVM(
private val app: LauncherApp
) : SearchableItemVM(app) {
private val notificationRepository: NotificationRepository by inject()
private val appShortcutRepository: AppShortcutRepository by inject()
val notifications =
notificationRepository.notifications.map { it.filter { it.packageName == app.`package` } }
fun clearNotification(notification: StatusBarNotification) {
notificationRepository.cancelNotification(notification)
}
fun openAppInfo(context: Context) {
val launcherApps = context.getSystemService<LauncherApps>()!!
launcherApps.startAppDetailsActivity(
ComponentName(app.`package`, app.activity),
app.getUser(),
null,
null
)
}
suspend fun shareApkFile(context: Context) {
val launcherApps = context.getSystemService<LauncherApps>()!!
val fileCopy = java.io.File(
context.cacheDir,
"${app.`package`}-${app.version}.apk"
)
withContext(Dispatchers.IO) {
try {
val user = (app as? LauncherApp)?.getUser()
val info = if (user != null) {
launcherApps.getApplicationInfo(app.`package`, 0, user)
} else {
context.packageManager.getApplicationInfo(app.`package`, 0)
}
val file = java.io.File(info.publicSourceDir)
try {
file.copyTo(fileCopy, false)
} catch (e: FileAlreadyExistsException) {
// Do nothing. If the file is already there we don't have to copy it again.
}
} catch (e: PackageManager.NameNotFoundException) {
CrashReporter.logException(e)
}
}
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uri = FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider",
fileCopy
)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.type = "application/vnd.android.package-archive"
withContext(Dispatchers.Main) {
context.startActivity(Intent.createChooser(shareIntent, null))
}
}
fun shareStoreLink(context: Context, url: String) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, url)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
val canUninstall = app.flags and ApplicationInfo.FLAG_SYSTEM == 0 && (app as? LauncherApp)?.getUser() == Process.myUserHandle()
fun uninstall(context: Context) {
val intent = Intent(Intent.ACTION_DELETE)
intent.data = Uri.parse("package:" + app.`package`)
context.startActivity(intent)
}
fun openNotification(notification: StatusBarNotification) {
try {
notification.notification.contentIntent?.send()
} catch (e: PendingIntent.CanceledException) {}
}
fun getShortcutIcon(context: Context, shortcut: ShortcutInfo) : Drawable? {
val launcherApps = context.getSystemService<LauncherApps>() ?: return null
return launcherApps.getShortcutIconDrawable(shortcut, 0)
}
val shortcuts = flow {
emit(appShortcutRepository.getShortcutsForActivity(app.launcherActivityInfo, 5))
}
fun isShortcutPinned(shortcut: AppShortcut): Flow<Boolean> {
return searchableRepository.isPinned(shortcut)
}
fun pinShortcut(shortcut: AppShortcut) {
favoritesService.pinItem(shortcut)
}
fun unpinShortcut(shortcut: AppShortcut) {
favoritesService.unpinItem(shortcut)
}
fun launchShortcut(context: Context, shortcut: AppShortcut) {
shortcut.launch(context, null)
}
}

View File

@ -1,16 +1,46 @@
package de.mm20.launcher2.ui.launcher.search.calendar
import androidx.compose.animation.*
import android.content.Context
import android.text.format.DateUtils
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.with
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Notes
import androidx.compose.material.icons.rounded.OpenInNew
import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
@ -29,9 +59,13 @@ import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
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 kotlinx.coroutines.launch
import palettes.TonalPalette
@ -44,7 +78,12 @@ fun CalendarItem(
onBack: () -> Unit
) {
val context = LocalContext.current
val viewModel = remember(calendar.key) { CalendarItemVM(calendar) }
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${calendar.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(calendar) {
viewModel.init(calendar, iconSize.toInt())
}
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
@ -54,7 +93,9 @@ fun CalendarItem(
Row(
modifier = modifier
.drawBehind {
val color = TonalPalette.fromInt(calendar.color).tone(
val color = TonalPalette
.fromInt(calendar.color)
.tone(
if (darkMode) 80 else 40
)
drawRect(Color(color), Offset.Zero, this.size.copy(width = 8.dp.toPx()))
@ -82,12 +123,12 @@ fun CalendarItem(
AnimatedVisibility(!showDetails) {
Text(
modifier = Modifier.padding(top = 2.dp),
text = viewModel.getSummary(context),
text = calendar.getSummary(context),
style = MaterialTheme.typography.bodySmall
)
}
AnimatedVisibility(showDetails) {
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
@ -112,7 +153,7 @@ fun CalendarItem(
contentDescription = null
)
Text(
text = viewModel.formatTime(context),
text = calendar.formatTime(context),
style = MaterialTheme.typography.bodySmall
)
}
@ -156,7 +197,7 @@ fun CalendarItem(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.openLocation(context)
calendar.openLocation(context)
}
) {
Icon(
@ -213,7 +254,10 @@ fun CalendarItem(
onBack()
lifecycleOwner.lifecycleScope.launch {
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.msg_item_hidden, calendar.label),
message = context.getString(
R.string.msg_item_hidden,
calendar.label
),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Short,
)
@ -297,3 +341,52 @@ fun CalendarItemGridPopup(
}
}
}
private fun CalendarEvent.formatTime(context: Context): String {
if (allDay) return DateUtils.formatDateRange(
context,
startTime,
endTime,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY
)
return DateUtils.formatDateRange(
context,
startTime,
endTime,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_WEEKDAY
)
}
private fun CalendarEvent.getSummary(context: Context): String {
val isToday =
DateUtils.isToday(startTime) && DateUtils.isToday(endTime)
return if (isToday) {
if (allDay) {
context.getString(R.string.calendar_event_allday)
} else {
DateUtils.formatDateRange(
context,
startTime,
endTime,
DateUtils.FORMAT_SHOW_TIME
)
}
} else {
if (allDay) {
DateUtils.formatDateRange(
context,
startTime,
endTime,
DateUtils.FORMAT_SHOW_DATE
)
} else {
DateUtils.formatDateRange(
context,
startTime,
endTime,
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE
)
}
}
}

View File

@ -1,82 +0,0 @@
package de.mm20.launcher2.ui.launcher.search.calendar
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.format.DateUtils
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import java.net.URLEncoder
class CalendarItemVM(
private val calendarEvent: CalendarEvent
) : SearchableItemVM(calendarEvent) {
fun getSummary(context: Context): String {
val isToday =
DateUtils.isToday(calendarEvent.startTime) && DateUtils.isToday(calendarEvent.endTime)
return if (isToday) {
if (calendarEvent.allDay) {
context.getString(R.string.calendar_event_allday)
} else {
DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_TIME
)
}
} else {
if (calendarEvent.allDay) {
DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_DATE
)
} else {
DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE
)
}
}
}
fun formatTime(context: Context): String {
if (calendarEvent.allDay) return DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY
)
return DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_WEEKDAY
)
}
fun openLocation(context: Context) {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW)
.setData(
Uri.parse(
"geo:0,0?q=${
URLEncoder.encode(
calendarEvent.location,
"utf8"
)
}"
)
)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}

View File

@ -1,60 +1,105 @@
package de.mm20.launcher2.ui.launcher.search.common
import android.content.Context
import android.content.pm.LauncherApps
import android.content.pm.ShortcutInfo
import android.graphics.drawable.Drawable
import android.service.notification.StatusBarNotification
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.geometry.Rect
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.getSystemService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.badges.BadgeService
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.files.FileRepository
import de.mm20.launcher2.icons.IconService
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.services.favorites.FavoritesService
import de.mm20.launcher2.services.tags.TagsService
import de.mm20.launcher2.ui.launcher.search.ListItemViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
abstract class SearchableItemVM(
private val searchable: SavableSearchable
) : KoinComponent {
protected val favoritesService: FavoritesService by inject()
protected val searchableRepository: SearchableRepository by inject()
protected val badgeService: BadgeService by inject()
protected val iconService: IconService by inject()
protected val customAttributesRepository: CustomAttributesRepository by inject()
class SearchableItemVM : ListItemViewModel(), KoinComponent {
private val favoritesService: FavoritesService by inject()
private val badgeService: BadgeService by inject()
private val iconService: IconService by inject()
private val tagsService: TagsService by inject()
private val notificationRepository: NotificationRepository by inject()
private val appShortcutRepository: AppShortcutRepository by inject()
private val fileRepository: FileRepository by inject()
private val searchable = MutableStateFlow<SavableSearchable?>(null)
private val iconSize = MutableStateFlow<Int>(0)
fun init(searchable: SavableSearchable, iconSize: Int) {
this.searchable.value = searchable
this.iconSize.value = iconSize
}
val isPinned = searchable.flatMapLatest {
if (it == null) emptyFlow() else favoritesService.isPinned(it)
}
val isPinned = searchableRepository.isPinned(searchable)
fun pin() {
favoritesService.pinItem(searchable)
searchable.value?.let { favoritesService.pinItem(it) }
}
fun unpin() {
favoritesService.unpinItem(searchable)
searchable.value?.let { favoritesService.unpinItem(it) }
}
val isHidden = searchable.flatMapLatest {
if (it == null) emptyFlow() else favoritesService.isHidden(it)
}
val isHidden = searchableRepository.isHidden(searchable)
fun hide() {
searchableRepository.upsert(searchable, hidden = true, pinned = false)
searchable.value?.let { favoritesService.hideItem(it) }
}
fun unhide() {
searchableRepository.update(searchable, hidden = false)
searchable.value?.let { favoritesService.unhideItem(it) }
}
val badge = badgeService.getBadge(searchable)
val badge = searchable.flatMapLatest {
if (it == null) emptyFlow() else badgeService.getBadge(it)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun getIcon(size: Int): Flow<LauncherIcon> {
return iconService.getIcon(searchable, size)
val icon = searchable.combine(iconSize) { sh, sz -> sh to sz }.flatMapLatest { (s, size) ->
if (s == null || size == 0) emptyFlow() else iconService.getIcon(s, size)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val tags = searchable.flatMapLatest {
if (it == null) emptyFlow() else tagsService.getTags(it)
}
fun getTags(): Flow<List<String>> {
return customAttributesRepository.getTags(searchable)
val notifications = searchable.flatMapLatest { searchable ->
if (searchable !is LauncherApp) emptyFlow()
else notificationRepository.notifications.map { it.filter { it.packageName == searchable.`package` } }
}
val shortcuts = searchable.map {
if (it !is LauncherApp) emptyList()
else appShortcutRepository.getShortcutsForActivity(it.launcherActivityInfo, 5)
}
open fun launch(context: Context, bounds: Rect? = null): Boolean {
val searchable = searchable.value ?: return false
val view = (context as? AppCompatActivity)?.window?.decorView
val options = if (bounds != null && view != null) {
ActivityOptionsCompat.makeScaleUpAnimation(
@ -72,8 +117,47 @@ abstract class SearchableItemVM(
favoritesService.reportLaunch(searchable)
return true
} else if (searchable is LauncherApp || searchable is AppShortcut) {
searchableRepository.delete(searchable)
favoritesService.reset(searchable)
}
return false
}
fun clearNotification(notification: StatusBarNotification) {
notificationRepository.cancelNotification(notification)
}
fun getShortcutIcon(context: Context, shortcut: ShortcutInfo): Drawable? {
val launcherApps = context.getSystemService<LauncherApps>() ?: return null
return launcherApps.getShortcutIconDrawable(shortcut, 0)
}
fun isShortcutPinned(shortcut: AppShortcut): Flow<Boolean> {
return favoritesService.isPinned(shortcut)
}
fun pinShortcut(shortcut: AppShortcut) {
favoritesService.pinItem(shortcut)
}
fun unpinShortcut(shortcut: AppShortcut) {
favoritesService.unpinItem(shortcut)
}
fun launchShortcut(context: Context, shortcut: AppShortcut) {
shortcut.launch(context, null)
}
fun delete() {
val searchable = searchable.value ?: return
if (searchable is File) fileRepository.deleteFile(searchable)
if (searchable is LauncherShortcut) appShortcutRepository.removePinnedShortcut(searchable)
favoritesService.reset(searchable)
}
public override fun onCleared() {
super.onCleared()
Log.d("SearchableItemVM", "onCleared: ${searchable.value?.key}")
}
}

View File

@ -35,6 +35,8 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.AppShortcut
@ -51,8 +53,10 @@ import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.search.apps.AppItemGridPopup
import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItemGridPopup
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import de.mm20.launcher2.ui.launcher.search.contacts.ContactItemGridPopup
import de.mm20.launcher2.ui.launcher.search.files.FileItemGridPopup
import de.mm20.launcher2.ui.launcher.search.listItemViewModel
import de.mm20.launcher2.ui.launcher.search.shortcut.ShortcutItemGridPopup
import de.mm20.launcher2.ui.launcher.search.website.WebsiteItemGridPopup
import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaItemGridPopup
@ -71,7 +75,12 @@ fun GridItem(
showLabels: Boolean = true,
highlight: Boolean = false
) {
val viewModel = remember(item.key) { GridItemVM(item) }
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${item.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(item, iconSize) {
viewModel.init(item, iconSize.toInt())
}
val context = LocalContext.current
@ -79,9 +88,8 @@ fun GridItem(
var bounds by remember { mutableStateOf(Rect.Zero) }
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
val badge by remember(item.key) { viewModel.badge }.collectAsState(null)
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
val icon by remember(item.key) { viewModel.getIcon(iconSize.toInt()) }.collectAsState(null)
val badge by remember(item.key) { viewModel.badge }.collectAsStateWithLifecycle()
val icon by viewModel.icon.collectAsStateWithLifecycle()
val launchOnPress = !item.preferDetailsOverLaunch

View File

@ -1,8 +0,0 @@
package de.mm20.launcher2.ui.launcher.search.common.grid
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class GridItemVM(
searchable: SavableSearchable
): SearchableItemVM(searchable)

View File

@ -8,13 +8,19 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.*
import de.mm20.launcher2.ui.component.InnerCard
import de.mm20.launcher2.ui.ktx.toPixels
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
import de.mm20.launcher2.ui.launcher.search.files.FileItem
import de.mm20.launcher2.ui.launcher.search.listItemViewModel
import de.mm20.launcher2.ui.launcher.search.shortcut.AppShortcutItem
import de.mm20.launcher2.ui.locals.LocalGridSettings
@Composable
fun ListItem(
@ -25,7 +31,12 @@ fun ListItem(
var showDetails by remember { mutableStateOf(false) }
val context = LocalContext.current
val viewModel = remember(item.key) { ListItemVM(item) }
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${item.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(item, iconSize) {
viewModel.init(item, iconSize.toInt())
}
var bounds by remember { mutableStateOf(Rect.Zero) }
InnerCard(

View File

@ -1,8 +0,0 @@
package de.mm20.launcher2.ui.launcher.search.common.list
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class ListItemVM(
searchable: SavableSearchable
): SearchableItemVM(searchable)

View File

@ -1,17 +1,47 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import androidx.compose.animation.*
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.*
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.with
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Email
import androidx.compose.material.icons.rounded.OpenInNew
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
@ -21,15 +51,23 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.*
import de.mm20.launcher2.ui.component.Chip
import de.mm20.launcher2.ui.component.DefaultToolbarAction
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.icons.Telegram
import de.mm20.launcher2.ui.icons.WhatsApp
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
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.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
@ -45,7 +83,13 @@ fun ContactItem(
onBack: () -> Unit
) {
val context = LocalContext.current
val viewModel = remember(contact) { ContactItemVM(contact) }
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${contact.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(contact, iconSize) {
viewModel.init(contact, iconSize.toInt())
}
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
@ -58,8 +102,7 @@ fun ContactItem(
Row(
verticalAlignment = Alignment.CenterVertically
) {
val iconSize = 48.dp.toPixels().toInt()
val icon by remember(contact) { viewModel.getIcon(iconSize) }.collectAsState(null)
val icon by viewModel.icon.collectAsStateWithLifecycle()
val padding by transition.animateDp(label = "iconPadding") {
if (it) 16.dp else 8.dp
}
@ -92,7 +135,7 @@ fun ContactItem(
)
}
AnimatedVisibility(showDetails) {
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp),
@ -125,7 +168,10 @@ fun ContactItem(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
@ -149,7 +195,10 @@ fun ContactItem(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
@ -173,7 +222,10 @@ fun ContactItem(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
@ -197,7 +249,10 @@ fun ContactItem(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
@ -221,7 +276,10 @@ fun ContactItem(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
@ -271,7 +329,10 @@ fun ContactItem(
onBack()
lifecycleOwner.lifecycleScope.launch {
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.msg_item_hidden, contact.label),
message = context.getString(
R.string.msg_item_hidden,
contact.label
),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Short,
)

View File

@ -1,20 +0,0 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.search.data.ContactInfo
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class ContactItemVM(
val contact: Contact
) : SearchableItemVM(contact) {
fun contact(context: Context, contactInfo: ContactInfo) {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(contactInfo.data))
)
}
}

View File

@ -19,7 +19,9 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
@ -29,6 +31,8 @@ import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
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.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
@ -46,7 +50,13 @@ fun FileItem(
onBack: () -> Unit
) {
val context = LocalContext.current
val viewModel = remember(file.key) { FileItemVM(file) }
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${file.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(file) {
viewModel.init(file, iconSize.toInt())
}
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
@ -85,7 +95,7 @@ fun FileItem(
}
AnimatedVisibility(showDetails) {
Column {
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
@ -128,8 +138,7 @@ fun FileItem(
}
}
val iconSize = 48.dp.toPixels().toInt()
val icon by remember(file) { viewModel.getIcon(iconSize) }.collectAsState(null)
val icon by viewModel.icon.collectAsStateWithLifecycle()
val badge by viewModel.badge.collectAsState(null)
val padding by transition.animateDp(label = "iconPadding") {
if (it) 16.dp else 8.dp
@ -180,17 +189,17 @@ fun FileItem(
)
)
if (viewModel.canShare) {
if (file.canShare) {
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
action = {
viewModel.share(context)
file.share(context)
}
))
}
if (viewModel.canDelete) {
if (file.isDeletable) {
var showConfirmDialog by remember { mutableStateOf(false) }
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_delete),

View File

@ -1,36 +0,0 @@
package de.mm20.launcher2.ui.launcher.search.files
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import de.mm20.launcher2.files.FileRepository
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LocalFile
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import org.koin.core.component.inject
class FileItemVM(
private val file: File
) : SearchableItemVM(file) {
private val fileRepository: FileRepository by inject()
val canShare = file is LocalFile
val canDelete = file.isDeletable
fun delete() {
fileRepository.deleteFile(file)
}
fun share(context: Context) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uri = FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider",
java.io.File(file.path)
)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.type = file.mimeType
context.startActivity(Intent.createChooser(shareIntent, null))
}
}

View File

@ -1,6 +1,9 @@
package de.mm20.launcher2.ui.launcher.search.shortcut
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
@ -16,8 +19,13 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.search.data.LegacyShortcut
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.DefaultToolbarAction
@ -26,6 +34,8 @@ import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
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.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
@ -42,9 +52,15 @@ fun AppShortcutItem(
showDetails: Boolean = false,
onBack: () -> Unit
) {
val viewModel = remember { ShortcutItemVM(shortcut) }
val context = LocalContext.current
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${shortcut.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(shortcut, iconSize) {
viewModel.init(shortcut, iconSize.toInt())
}
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
@ -70,7 +86,7 @@ fun AppShortcutItem(
)
AnimatedVisibility(showDetails) {
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 2.dp),
@ -95,8 +111,7 @@ fun AppShortcutItem(
}
val badge by viewModel.badge.collectAsState(null)
val size by animateDpAsState(if (showDetails) 84.dp else 48.dp)
val iconSize = 84.dp.toPixels().toInt()
val icon by remember(shortcut.key) { viewModel.getIcon(iconSize) }.collectAsState(null)
val icon by viewModel.icon.collectAsStateWithLifecycle()
val padding by transition.animateDp(label = "iconPadding") {
if (it) 16.dp else 8.dp
@ -136,13 +151,21 @@ fun AppShortcutItem(
toolbarActions.add(favAction)
}
val packageName = shortcut.packageName
if (packageName != null) {
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_app_info),
icon = Icons.Rounded.Info
) {
viewModel.openAppInfo(context)
context.tryStartActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
})
}
val sheetManager = LocalBottomSheetManager.current
@ -152,7 +175,7 @@ fun AppShortcutItem(
action = { sheetManager.showCustomizeSearchableModal(shortcut) }
))
if (viewModel.canDelete) {
if (shortcut is LauncherShortcut && shortcut.launcherShortcut.isPinned) {
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_delete),
icon = Icons.Rounded.Delete,
@ -215,7 +238,7 @@ fun AppShortcutItem(
text = { Text(stringResource(R.string.alert_delete_shortcut, shortcut.label)) },
confirmButton = {
TextButton(onClick = {
viewModel.deleteShortcut()
viewModel.delete()
requestDelete = false
}) {
Text(stringResource(android.R.string.ok))
@ -279,3 +302,10 @@ fun ShortcutItemGridPopup(
}
}
}
val AppShortcut.packageName: String?
get() = when (this) {
is LegacyShortcut -> intent.`package`
is LauncherShortcut -> launcherShortcut.`package`
else -> null
}

View File

@ -1,41 +0,0 @@
package de.mm20.launcher2.ui.launcher.search.shortcut
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.search.data.LegacyShortcut
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class ShortcutItemVM(private val shortcut: AppShortcut) : SearchableItemVM(shortcut), KoinComponent {
private val shortcutRepository: AppShortcutRepository by inject()
val canDelete = shortcut is LauncherShortcut && shortcut.launcherShortcut.isPinned
fun openAppInfo(context: Context) {
val packageName = when(shortcut) {
is LegacyShortcut -> shortcut.intent.`package` ?: return
is LauncherShortcut -> shortcut.launcherShortcut.`package`
else -> return
}
context.tryStartActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
}
fun deleteShortcut() {
if (!canDelete) return
if (shortcut is LauncherShortcut) shortcutRepository.removePinnedShortcut(shortcut)
searchableRepository.delete(shortcut)
}
}

View File

@ -16,6 +16,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.ui.component.DefaultToolbarAction
@ -23,8 +24,12 @@ import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
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.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
@Composable
fun WebsiteItem(
@ -33,7 +38,13 @@ fun WebsiteItem(
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val viewModel = remember(website) { WebsiteItemVM(website) }
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${website.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(website, iconSize) {
viewModel.init(website, iconSize.toInt())
}
Column(
modifier = modifier.clickable {
@ -57,7 +68,7 @@ fun WebsiteItem(
text = website.labelOverride ?: website.label,
style = MaterialTheme.typography.titleLarge
)
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 2.dp, bottom = 2.dp),
@ -102,7 +113,7 @@ fun WebsiteItem(
label = stringResource(R.string.menu_share),
icon= Icons.Rounded.Share,
action = {
viewModel.share(context)
website.share(context)
}
)
)

View File

@ -1,21 +0,0 @@
package de.mm20.launcher2.ui.launcher.search.website
import android.content.Context
import android.content.Intent
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class WebsiteItemVM(
private val website: Website
) : SearchableItemVM(website) {
fun share(context: Context) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(
Intent.EXTRA_TEXT,
"${website.label}\n\n${website.description}\n\n${website.url}"
)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
}

View File

@ -16,6 +16,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.ui.R
@ -23,8 +24,12 @@ import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
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.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.utils.htmlToAnnotatedString
@Composable
@ -34,7 +39,13 @@ fun WikipediaItem(
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val viewModel = remember(wikipedia) { WikipediaItemVM(wikipedia) }
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${wikipedia.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(wikipedia, iconSize) {
viewModel.init(wikipedia, iconSize.toInt())
}
Column(
modifier = modifier.clickable {
@ -59,7 +70,7 @@ fun WikipediaItem(
text = wikipedia.label,
style = MaterialTheme.typography.titleLarge
)
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
@ -110,7 +121,7 @@ fun WikipediaItem(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
action = {
viewModel.share(context)
wikipedia.share(context)
}
)
)

View File

@ -1,24 +0,0 @@
package de.mm20.launcher2.ui.launcher.search.wikipedia
import android.content.Context
import android.content.Intent
import androidx.core.text.HtmlCompat
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class WikipediaItemVM(
private val wikipedia: Wikipedia
) : SearchableItemVM(wikipedia) {
fun share(context: Context) {
val text = HtmlCompat.fromHtml(wikipedia.text, HtmlCompat.FROM_HTML_MODE_LEGACY).toString()
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(
Intent.EXTRA_TEXT, "${wikipedia.label}\n\n" +
"${text.substring(0, 200)}\n\n" +
"${wikipedia.wikipediaUrl}/wiki?curid=${wikipedia.id}"
)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
}

View File

@ -3,14 +3,18 @@ package de.mm20.launcher2.search.data
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.LauncherActivityInfo
import android.content.pm.LauncherApps
import android.content.pm.PackageManager
import android.graphics.drawable.AdaptiveIconDrawable
import android.net.Uri
import android.os.Bundle
import android.os.Process
import android.os.UserHandle
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.getSystemService
import de.mm20.launcher2.applications.R
import de.mm20.launcher2.compat.PackageManagerCompat
@ -44,6 +48,9 @@ data class LauncherApp(
val isMainProfile = launcherActivityInfo.user == Process.myUserHandle()
val canUninstall: Boolean
get() = flags and ApplicationInfo.FLAG_SYSTEM == 0 && isMainProfile
override val domain: String = Domain
override val preferDetailsOverLaunch: Boolean = false
@ -148,7 +155,60 @@ data class LauncherApp(
}
}
fun uninstall(context: Context) {
val intent = Intent(Intent.ACTION_DELETE)
intent.data = Uri.parse("package:$`package`")
context.startActivity(intent)
}
fun openAppInfo(context: Context) {
val launcherApps = context.getSystemService<LauncherApps>()!!
launcherApps.startAppDetailsActivity(
ComponentName(`package`, activity),
getUser(),
null,
null
)
}
suspend fun shareApkFile(context: Context) {
val launcherApps = context.getSystemService<LauncherApps>()!!
val fileCopy = java.io.File(
context.cacheDir,
"${`package`}-${version}.apk"
)
withContext(Dispatchers.IO) {
try {
val user = getUser()
val info = if (user != null) {
launcherApps.getApplicationInfo(`package`, 0, user)
} else {
context.packageManager.getApplicationInfo(`package`, 0)
}
val file = java.io.File(info.publicSourceDir)
try {
file.copyTo(fileCopy, false)
} catch (e: FileAlreadyExistsException) {
// Do nothing. If the file is already there we don't have to copy it again.
}
} catch (e: PackageManager.NameNotFoundException) {
}
}
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uri = FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider",
fileCopy
)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.type = "application/vnd.android.package-archive"
withContext(Dispatchers.Main) {
context.startActivity(Intent.createChooser(shareIntent, null))
}
}
companion object {
private fun getStoreLinkForInstaller(

View File

@ -3,6 +3,7 @@ package de.mm20.launcher2.search.data
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.CalendarContract
import de.mm20.launcher2.icons.ColorLayer
@ -10,6 +11,7 @@ import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.SavableSearchable
import java.net.URLEncoder
import java.text.SimpleDateFormat
data class CalendarEvent(
@ -57,6 +59,23 @@ data class CalendarEvent(
return context.tryStartActivity(getLaunchIntent(), options)
}
fun openLocation(context: Context) {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW)
.setData(
Uri.parse(
"geo:0,0?q=${
URLEncoder.encode(
location,
"utf8"
)
}"
)
)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
companion object {
const val Domain = "calendar"
}

View File

@ -129,6 +129,11 @@ interface File : SavableSearchable {
val isDeletable: Boolean
get() = false
val canShare: Boolean
get() = false
fun share(context: Context) {}
suspend fun delete(context: Context) {}
}

View File

@ -335,4 +335,20 @@ data class LocalFile(
return metaData
}
}
override val canShare: Boolean
get() = !isDirectory
override fun share(context: Context) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uri = FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider",
java.io.File(path)
)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.type = mimeType
context.startActivity(Intent.createChooser(shareIntent, null))
}
}

View File

@ -90,6 +90,16 @@ data class Website(
return context.tryStartActivity(getLaunchIntent(), options)
}
fun share(context: Context) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(
Intent.EXTRA_TEXT,
"${label}\n\n${description}\n\n${url}"
)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
companion object {
const val Domain = "web"
}

View File

@ -7,6 +7,7 @@ import android.net.Uri
import android.os.Bundle
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TintedIconLayer
@ -53,6 +54,18 @@ data class Wikipedia(
return context.tryStartActivity(getLaunchIntent(), options)
}
fun share(context: Context) {
val text = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY).toString()
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(
Intent.EXTRA_TEXT, "${label}\n\n" +
"${text.substring(0, 200)}\n\n" +
url
)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
companion object {
const val Domain = "wikipedia"
}

View File

@ -31,6 +31,10 @@ class FavoritesService(
return searchableRepository.isPinned(searchable)
}
fun isHidden(searchable: SavableSearchable): Flow<Boolean> {
return searchableRepository.isHidden(searchable)
}
fun pinItem(searchable: SavableSearchable) {
searchableRepository.upsert(
searchable,
@ -56,6 +60,21 @@ class FavoritesService(
)
}
fun hideItem(searchable: SavableSearchable) {
searchableRepository.upsert(
searchable,
hidden = true,
pinned = false,
)
}
fun unhideItem(searchable: SavableSearchable) {
searchableRepository.upsert(
searchable,
hidden = false,
)
}
fun reportLaunch(searchable: SavableSearchable) {
searchableRepository.touch(searchable)
}

View File

@ -12,4 +12,5 @@ interface TagsService {
fun createTag(tag: String, items: List<SavableSearchable>)
fun updateTag(tag: String, newName: String? = null, items: List<SavableSearchable>? = null)
fun getTags(it: SavableSearchable): Flow<List<String>>
}

View File

@ -64,4 +64,8 @@ internal class TagsServiceImpl(
customAttributesRepository.setItemsForTag(tag, items)
}
}
override fun getTags(it: SavableSearchable): Flow<List<String>> {
return customAttributesRepository.getTags(it)
}
}