diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index c1e867ae..f4f0914a 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -13,10 +13,10 @@ jobs:
steps:
- uses: actions/checkout@v3
- - name: set up JDK 11
+ - name: set up JDK 17
uses: actions/setup-java@v3
with:
- java-version: '11'
+ java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Decode release google-services.json
diff --git a/app/build.gradle b/app/build.gradle
index 4e4e1600..65340c2f 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,57 +1,63 @@
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
-apply plugin: 'kotlin-kapt'
-apply plugin: "androidx.navigation.safeargs.kotlin"
-apply plugin: 'com.google.firebase.crashlytics'
-apply plugin: 'com.google.gms.google-services'
-apply plugin: 'dagger.hilt.android.plugin'
-apply plugin: 'com.mikepenz.aboutlibraries.plugin'
-apply plugin: 'com.google.firebase.firebase-perf'
-apply plugin: 'com.github.triplet.play'
+plugins {
+ id "com.android.application"
+ id "com.google.gms.google-services"
+ id "com.google.firebase.crashlytics"
+ id "kotlin-android"
+ id "kotlin-kapt"
+ id 'androidx.navigation.safeargs.kotlin'
+ id 'com.google.dagger.hilt.android'
+ id 'com.github.triplet.play' version '3.8.4'
+ id 'com.google.devtools.ksp'
+ id 'jacoco'
+}
android {
- compileSdkVersion Config.compile_sdk
- buildToolsVersion Config.build_tools
+ namespace "de.psdev.devdrawer"
+ compileSdk = 34
defaultConfig {
+ namespace
applicationId "de.psdev.devdrawer"
- minSdkVersion Config.min_sdk
- targetSdkVersion Config.target_sdk
+ minSdkVersion 26
+ targetSdkVersion 34
versionCode project.ext.appVersionCode
versionName project.ext.appVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
-
- resConfig "en"
+ resourceConfigurations += ['en']
// Version info
buildConfigField 'String', 'GIT_SHA', "\"${project.ext.gitHash}\""
- javaCompileOptions.annotationProcessorOptions.arguments['room.schemaLocation'] = rootProject.file('schemas').toString()
+ vectorDrawables {
+ useSupportLibrary true
+ }
}
buildFeatures {
viewBinding true
+ compose true
+ buildConfig true
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
- freeCompilerArgs += [
- "-Xinline-classes",
- "-Xopt-in=kotlin.RequiresOptIn",
- "-Xopt-in=kotlin.ExperimentalStdlibApi",
- "-Xopt-in=kotlin.time.ExperimentalTime",
- "-Xopt-in=kotlinx.coroutines.FlowPreview",
- "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
- ]
+ jvmTarget = '17'
}
testOptions {
+ managedDevices {
+ devices {
+ pixel4api31(com.android.build.api.dsl.ManagedVirtualDevice) {
+ device = "Pixel 4"
+ apiLevel = 31
+ systemImageSource = "google"
+ require64Bit = true
+ }
+ }
+ }
unitTests {
includeAndroidResources = true
- all { ignoreFailures = true }
}
}
final def keystorePropertiesFile = rootProject.file("release.properties")
@@ -92,114 +98,183 @@ android {
}
}
}
- lintOptions {
- lintConfig project.file('lint.xml')
- disable "GoogleAppIndexingWarning"
- disable "RemoveWorkManagerInitializer"
+ packagingOptions {
+ resources {
+ excludes += ['**/LICENSE', '**/LICENSE.txt', '**/NOTICE', '**/NOTICE.txt', '**/*.gwt.xml']
+ }
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.3"
+ }
+ lint {
+ disable 'GoogleAppIndexingWarning', 'RemoveWorkManagerInitializer'
enable 'Interoperability'
+ lintConfig file('lint.xml')
}
- packagingOptions {
- exclude '**/LICENSE'
- exclude '**/LICENSE.txt'
- exclude '**/NOTICE'
- exclude '**/NOTICE.txt'
- exclude '**/*.gwt.xml'
+ applicationVariants.configureEach { variant ->
+ kotlin.sourceSets {
+ named(variant.name) {
+ kotlin.srcDir("build/generated/ksp/${variant.name}/kotlin")
+ }
+ }
+ }
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
}
}
+ksp {
+ arg("room.schemaLocation", rootProject.file('schemas').toString())
+}
+
+
dependencies {
//
// Platforms
//
- implementation platform(Platforms.firebase)
- implementation platform(Platforms.kotlin)
+ implementation platform("com.google.firebase:firebase-bom:32.4.0")
//
// Test dependencies
//
- testImplementation Libs.junit
- testImplementation Libs.robolectric
- testImplementation Libs.mockk
+ testImplementation libs.junit
+ testImplementation libs.robolectric
+ def mockkVersion = "1.13.8"
+ testImplementation "io.mockk:mockk-android:${mockkVersion}"
+ testImplementation "io.mockk:mockk-agent:${mockkVersion}"
//
// Runtime dependencies
//
// AboutLibraries
- implementation Libs.about_libraries
+ def latestAboutLibsRelease = "10.9.1"
+ implementation "com.mikepenz:aboutlibraries-core:${latestAboutLibsRelease}"
+ implementation "com.mikepenz:aboutlibraries-compose:${latestAboutLibsRelease}"
+ implementation "com.mikepenz:aboutlibraries:${latestAboutLibsRelease}"
+
// AndroidX
- implementation Libs.androidx_appcompat
- implementation Libs.androidx_browser
- implementation Libs.androidx_constraint_layout
- implementation Libs.androidx_core
- implementation Libs.androidx_fragment
- implementation Libs.androidx_hilt_work
- implementation Libs.androidx_lifecycle_viewmodel
- implementation Libs.androidx_lifecycle_java8
- implementation Libs.androidx_lifecycle_process
- implementation Libs.androidx_navigation_fragment
- implementation Libs.androidx_navigation_ui
- implementation Libs.androidx_preference
- implementation Libs.androidx_recyclerview
- implementation Libs.androidx_recyclerview_selection
- implementation Libs.androidx_room_runtime
- implementation Libs.androidx_room_ktx
- implementation Libs.androidx_work_runtime
- implementation Libs.androidx_work_gcm
- kapt Libs.androidx_room_compiler
- kapt Libs.androidx_hilt_compiler
-
- // Android Material
- implementation Libs.material_components
+ def appcompat_version = "1.6.1"
+ implementation "androidx.appcompat:appcompat:$appcompat_version"
+ implementation "androidx.browser:browser:1.6.0"
+ implementation "androidx.constraintlayout:constraintlayout:2.1.4"
+ def core_version = "1.12.0"
+ implementation "androidx.core:core-ktx:$core_version"
+
+ implementation "androidx.core:core-splashscreen:1.0.1"
+ def fragment_version = "1.6.1"
+ implementation "androidx.fragment:fragment-ktx:$fragment_version"
+ // Testing Fragments in Isolation
+ debugImplementation "androidx.fragment:fragment-testing:$fragment_version"
+
+ implementation "com.google.dagger:hilt-android:2.48.1"
+ kapt "com.google.dagger:hilt-compiler:2.48.1"
+ implementation 'androidx.hilt:hilt-work:1.0.0'
+ implementation 'androidx.hilt:hilt-navigation-fragment:1.0.0'
+ implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
+ // When using Kotlin.
+ kapt 'androidx.hilt:hilt-compiler:1.0.0'
+
+ implementation libs.androidx.lifecycle.viewmodel.ktx
+ implementation libs.androidx.lifecycle.livedata.ktx
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation libs.androidx.lifecycle.common.java8
+ // optional - Test helpers for Lifecycle runtime
+ testImplementation libs.androidx.lifecycle.runtime.testing
+ // optional - ProcessLifecycleOwner provides a lifecycle for the whole application process
+ implementation libs.androidx.lifecycle.process
+ def nav_version = "2.7.4"
+ implementation libs.androidx.navigation.fragment.ktx
+ implementation libs.androidx.navigation.ui.ktx
+ implementation "androidx.navigation:navigation-compose:$nav_version"
+ // Jetpack Compose Integration
+ implementation "androidx.navigation:navigation-compose:$nav_version"
+ implementation libs.androidx.preference.ktx
+ implementation libs.androidx.recyclerview
+ // For control over item selection of both touch and mouse driven selection
+ implementation libs.androidx.recyclerview.selection
+ implementation libs.androidx.room.runtime
+ ksp libs.androidx.room.compiler
+ implementation libs.androidx.room.ktx
+
+ implementation libs.androidx.work.runtime.ktx
+ androidTestImplementation libs.androidx.work.testing
+
+ def composeBom = platform(libs.androidx.compose.bom)
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+ implementation 'androidx.compose.material3:material3'
+ // Android Studio Preview support
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+ // UI Tests
+ androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
+ debugImplementation 'androidx.compose.ui:ui-test-manifest'
+ // Optional - Add full set of material icons
+ implementation 'androidx.compose.material:material-icons-extended'
+ // Optional - Add window size utils
+ implementation 'androidx.compose.material3:material3-window-size-class'
+ // Optional - Integration with activities
+ implementation libs.androidx.activity.compose
+ // Optional - Integration with ViewModels
+ implementation libs.androidx.lifecycle.viewmodel.compose
// Color Picker
- implementation "com.github.dhaval2404:colorpicker:2.0"
+ implementation libs.colorpicker
- // Dagger
- implementation Libs.daggerHiltAndroid
- kapt Libs.daggerHiltAndroidCompiler
+ // Compose Destinations
+ implementation libs.compose.destinations.core
+ ksp libs.compose.destinations.ksp
// Firebase
- implementation "com.google.firebase:firebase-analytics-ktx"
- implementation "com.google.firebase:firebase-config-ktx"
- implementation "com.google.firebase:firebase-crashlytics-ktx"
- implementation "com.google.firebase:firebase-perf-ktx"
+ implementation "com.google.firebase:firebase-analytics"
+ implementation "com.google.firebase:firebase-config"
+ implementation "com.google.firebase:firebase-crashlytics"
+ implementation "com.google.firebase:firebase-perf"
// FlowBinding
- implementation Libs.flowBindingAndroid
- implementation Libs.flowBindingCommon
- implementation Libs.flowBindingMaterial
+ implementation libs.flowbinding.android
+ implementation libs.flowbinding.material
// Google Play
- implementation Libs.googlePlayCore
- implementation Libs.googlePlayCoreKtx
+ implementation 'com.google.android.play:review:2.0.1'
+ implementation 'com.google.android.play:review-ktx:2.0.1'
+ implementation 'com.google.android.play:app-update:2.0.1'
+ implementation 'com.google.android.play:app-update-ktx:2.0.1'
// Kotlin
- implementation Libs.kotlinStdlib
+ implementation "org.jetbrains.kotlin:kotlin-stdlib"
// Kotlin Coroutines
- implementation Libs.kotlinCoroutinesAndroid
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// LeakCanary
- debugImplementation Libs.leakCanary
- implementation Libs.leakCanaryPlumberAndroid
+// debugImplementation Libs.leakCanary
+// implementation Libs.leakCanaryPlumberAndroid
// Logging
- implementation Libs.slf4jAndroidLogger
- implementation Libs.kotlinLogging
+ implementation "de.psdev.slf4j-android-logger:slf4j-android-logger:1.0.5"
+ implementation "io.github.microutils:kotlin-logging:2.1.23"
// OkHttp
- implementation Libs.okhttp
+ implementation("com.squareup.okhttp3:okhttp:4.11.0")
// Okio
- implementation Libs.okio
+ implementation("com.squareup.okio:okio:3.6.0")
}
kapt {
correctErrorTypes true
}
+jacoco {
+ toolVersion = "0.8.7"
+}
+
play {
def serviceAccountFileName = "google-play-api.json"
if (rootProject.file(serviceAccountFileName).exists()) {
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
index fa0c0f22..325b6206 100644
--- a/app/src/debug/res/values/strings.xml
+++ b/app/src/debug/res/values/strings.xml
@@ -1,4 +1,4 @@
- DevDrawer2 (Debug)
+ DevDrawer2 (Debug)
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 78d9bcdf..86a1f7f7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,7 +1,6 @@
+ xmlns:tools="http://schemas.android.com/tools">
@@ -13,13 +12,17 @@
-
+
@@ -30,6 +33,7 @@
android:name=".appwidget.ClickHandlingActivity"
android:allowTaskReparenting="false"
android:excludeFromRecents="true"
+ android:exported="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.NoDisplay">
@@ -46,13 +50,17 @@
android:taskAffinity=""
android:theme="@style/AppTheme.Dialog.NoActionBar" />
-
+
-
+
@@ -65,6 +73,10 @@
android:name=".receivers.UpdateReceiver"
android:exported="false" />
+
+
@@ -76,7 +88,7 @@
android:exported="false"
tools:node="merge">
diff --git a/app/src/main/java/de/psdev/devdrawer/BaseFragment.kt b/app/src/main/java/de/psdev/devdrawer/BaseFragment.kt
index dee9748a..bfe372f0 100644
--- a/app/src/main/java/de/psdev/devdrawer/BaseFragment.kt
+++ b/app/src/main/java/de/psdev/devdrawer/BaseFragment.kt
@@ -1,59 +1,26 @@
package de.psdev.devdrawer
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.lifecycleScope
-import androidx.viewbinding.ViewBinding
import de.psdev.devdrawer.analytics.TrackingService
import javax.inject.Inject
-abstract class BaseFragment : Fragment() {
+open class BaseFragment : Fragment() {
@Inject
lateinit var trackingService: TrackingService
-
- private var _binding: T? = null
- // This property is only valid between onCreateView and onDestroyView.
- protected val binding get() = _binding!!
-
protected var toolbarTitle: CharSequence
get() = requireActivity().title
set(value) {
requireActivity().title = value
}
-
- final override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View = createViewBinding(inflater, container, savedInstanceState).also { viewBinding ->
- _binding = viewBinding
- }.root
-
- protected abstract fun createViewBinding(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): T
-
- @CallSuper
- override fun onDestroyView() {
- super.onDestroyView()
- _binding = null
- }
+ val Fragment.viewLifecycleScope: LifecycleCoroutineScope
+ get() = viewLifecycleOwner.lifecycleScope
protected fun updateToolbarTitle(@StringRes resId: Int) {
requireActivity().setTitle(resId)
trackingService.trackScreen(this::class.java, getString(resId))
}
-
- val Fragment.viewLifecycleScope: LifecycleCoroutineScope
- get() = viewLifecycleOwner.lifecycleScope
-
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt
new file mode 100644
index 00000000..8c2b70ee
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt
@@ -0,0 +1,159 @@
+package de.psdev.devdrawer
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Grid3x3
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Widgets
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.compose.rememberNavController
+import com.ramcosta.composedestinations.navigation.navigate
+import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
+import de.psdev.devdrawer.destinations.Destination
+import de.psdev.devdrawer.destinations.SettingsScreenDestination
+import de.psdev.devdrawer.destinations.WidgetListScreenDestination
+import de.psdev.devdrawer.destinations.WidgetProfilesScreenDestination
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import mu.KotlinLogging
+
+
+private val logger = KotlinLogging.logger { }
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DevDrawerApp() {
+ DevDrawerTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ val navController = rememberNavController()
+ val currentDestination: Destination = navController.appCurrentDestinationAsState().value
+ ?: NavGraphs.root.startAppDestination
+ val (menuComposable, setMenu) = remember {
+ mutableStateOf(null)
+ }
+ // Reset state on navigation change
+ LaunchedEffect(key1 = true) {
+ navController.addOnDestinationChangedListener { _, _, _ ->
+ setMenu(null)
+ }
+ }
+ val navigationIcon: @Composable () -> Unit =
+ if (currentDestination.route !in topLevelRoutes) {
+ {
+ IconButton(onClick = { navController.popBackStack() }) {
+ Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ } else {
+ {}
+ }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ modifier = Modifier.statusBarsPadding(),
+ navigationIcon = navigationIcon,
+ title = {
+ Text(text = stringResource(id = currentDestination.title))
+ },
+ actions = {
+ logger.warn { "Actions: $menuComposable" }
+ menuComposable?.invoke(this)
+
+ // TODO Make this dynamically provided by each destination
+// OverflowMenu {
+// DropdownMenuItem(onClick = {
+// navController.navigate(SettingsScreenDestination)
+// closeMenu()
+// }) {
+// Text(text = stringResource(id = R.string.settings))
+// }
+// }
+ }
+ )
+ },
+ content = {
+ DevDrawerHost(navController, setMenu, modifier = Modifier.padding(it))
+ },
+ bottomBar = {
+ NavigationBar(
+ modifier = Modifier
+ .navigationBarsPadding()
+ .imePadding()
+ ) {
+ BottomBarDestination.values().forEach { destination ->
+ NavigationBarItem(
+ selected = currentDestination == destination.direction,
+ icon = {
+ Icon(
+ imageVector = destination.icon,
+ contentDescription = null
+ )
+ },
+ label = { Text(stringResource(destination.label)) },
+ onClick = {
+ navController.navigate(destination.direction) {
+ // Pop up to the start destination of the graph to
+ // avoid building up a large stack of destinations
+ // on the back stack as users select items
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ // Avoid multiple copies of the same destination when
+ // re-selecting the same item
+ launchSingleTop = true
+ // Restore state when re-selecting a previously selected item
+ restoreState = true
+ }
+ }
+ )
+ }
+ }
+ }
+ )
+ }
+ }
+}
+
+val topLevelRoutes = listOf(
+ WidgetListScreenDestination,
+ WidgetProfilesScreenDestination,
+ SettingsScreenDestination
+).map { it.route }
+
+enum class BottomBarDestination(
+ val direction: DirectionDestinationSpec,
+ val icon: ImageVector,
+ @StringRes val label: Int
+) {
+ Widgets(WidgetListScreenDestination, Icons.Default.Widgets, R.string.widgets),
+ Profiles(WidgetProfilesScreenDestination, Icons.Default.Grid3x3, R.string.profiles),
+ Settings(SettingsScreenDestination, Icons.Default.Settings, R.string.settings)
+}
+
+@Preview
+@Composable
+fun Preview_DevDrawerApp() {
+ DevDrawerApp()
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt
index 06ce493d..86e192d1 100644
--- a/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt
+++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt
@@ -29,7 +29,13 @@ class DevDrawerApplication: Application(), Configuration.Provider {
registerAppInstallationReceiver()
setupWorkers()
}.let {
- logger.warn("{} version {} ({}) took {}ms to init", this::class.java.simpleName, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, it)
+ logger.warn(
+ "{} version {} ({}) took {}ms to init",
+ this::class.java.simpleName,
+ BuildConfig.VERSION_NAME,
+ BuildConfig.VERSION_CODE,
+ it
+ )
}
}
@@ -37,12 +43,9 @@ class DevDrawerApplication: Application(), Configuration.Provider {
// Configuration.Provider
// ==========================================================================================================================
- override fun getWorkManagerConfiguration(): Configuration {
- logger.warn { "getWorkManagerConfiguration" }
- return Configuration.Builder()
- .setWorkerFactory(workerFactory)
- .build()
- }
+ override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
// ==========================================================================================================================
// Private API
diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt
new file mode 100644
index 00000000..fa9d2d1b
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt
@@ -0,0 +1,70 @@
+package de.psdev.devdrawer
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import com.ramcosta.composedestinations.DestinationsNavHost
+import com.ramcosta.composedestinations.navigation.dependency
+
+@Composable
+fun DevDrawerHost(
+ navController: NavHostController,
+ menuCallback: AppBarMenuCallback,
+ modifier: Modifier = Modifier
+) {
+ DestinationsNavHost(
+ navController = navController,
+ navGraph = NavGraphs.root,
+ modifier = modifier,
+ dependenciesContainerBuilder = {
+ dependency(menuCallback)
+ }
+ ) {
+// composable(Widgets.route) {
+// val activity = LocalContext.current.getActivity()!!
+// WidgetListScreen(
+// onWidgetClick = { widget ->
+// navController.navigate(WidgetEditorDestination(widget).route)
+// },
+// onRequestPinWidgetClick = {
+// val appWidgetManager: AppWidgetManager = activity.getSystemService()!!
+// val widgetProvider = ComponentName(activity, DDWidgetProvider::class.java)
+// if (appWidgetManager.isRequestPinAppWidgetSupported) {
+// val intent = PinWidgetSuccessReceiver.intent(activity)
+// val successCallback = PendingIntent.getBroadcast(
+// activity,
+// 1,
+// intent,
+// PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ONE_SHOT
+// )
+// val bundle = bundleOf()
+// appWidgetManager.requestPinAppWidget(
+// widgetProvider,
+// bundle,
+// successCallback
+// )
+// }
+// }
+// )
+// }
+// composable(Widgets.route + "/{widgetId}", arguments = listOf(
+// navArgument("widgetId") {
+// type = NavType.IntType
+// }
+// )) {
+// WidgetEditor(
+// onEditWidgetProfile = {
+// navController.navigate(ProfileEditorDestination(it).route)
+// }
+// )
+// }
+// widgetProfileGraph(navController)
+// composable(Settings.route) {
+// SettingsScreen()
+// }
+// composable(AppInfo.route) {
+//
+// }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerScreen.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerScreen.kt
new file mode 100644
index 00000000..42865e68
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerScreen.kt
@@ -0,0 +1,56 @@
+package de.psdev.devdrawer
+
+import androidx.annotation.StringRes
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Grid3x3
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Widgets
+import androidx.compose.ui.graphics.vector.ImageVector
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.database.WidgetProfile
+
+sealed class DevDrawerScreen(
+ val route: String
+)
+
+sealed class TopLevelScreen(route: String): DevDrawerScreen(route) {
+ abstract val icon: ImageVector
+ @get:StringRes abstract val label: Int
+}
+
+data object Widgets : TopLevelScreen(
+ route = "widgets"
+) {
+ override val icon: ImageVector = Icons.Filled.Widgets
+ override val label: Int = R.string.widgets
+}
+
+data object Profiles : TopLevelScreen(
+ route = "profiles"
+) {
+ override val icon: ImageVector = Icons.Filled.Grid3x3
+ override val label: Int = R.string.profiles
+}
+
+data object Settings : TopLevelScreen(
+ route = "settings"
+) {
+ override val icon: ImageVector = Icons.Filled.Settings
+ override val label: Int = R.string.settings
+}
+
+data object AppInfo : TopLevelScreen(
+ route = "info"
+) {
+ override val icon: ImageVector = Icons.Filled.Info
+ override val label: Int = R.string.app_info
+}
+
+data class WidgetEditorDestination(
+ val widget: Widget
+): DevDrawerScreen(route = "widgets/${widget.id}")
+
+data class ProfileEditorDestination(
+ val widgetProfile: WidgetProfile
+): DevDrawerScreen(route = "profiles/${widgetProfile.id}")
diff --git a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt
index 5b118f56..07d0242b 100644
--- a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt
+++ b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt
@@ -1,50 +1,33 @@
package de.psdev.devdrawer
import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
-import androidx.navigation.findNavController
-import androidx.navigation.ui.AppBarConfiguration
-import androidx.navigation.ui.setupWithNavController
+import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
-import de.psdev.devdrawer.database.DevDrawerDatabase
-import de.psdev.devdrawer.databinding.ActivityMainBinding
+import kotlinx.coroutines.launch
import mu.KLogging
-import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : BaseActivity() {
-
companion object : KLogging()
- private lateinit var binding: ActivityMainBinding
-
- @Inject
- lateinit var devDrawerDatabase: DevDrawerDatabase
-
// ==========================================================================================================================
// Android Lifecycle
// ==========================================================================================================================
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- binding = ActivityMainBinding.inflate(layoutInflater)
- setContentView(binding.root)
- setSupportActionBar(binding.toolbar)
-
- val navController = findNavController(R.id.nav_host_fragment)
- val appBarConfiguration = AppBarConfiguration.Builder(
- R.id.widget_list_fragment,
- R.id.profiles_list_fragment,
- R.id.settings_fragment,
- R.id.about_fragment
- ).build()
- binding.toolbar.setupWithNavController(navController, appBarConfiguration)
- binding.navbar.setupWithNavController(navController)
-
- lifecycleScope.launchWhenResumed {
- trackingService.checkOptIn(this@MainActivity)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ setContent {
+ DevDrawerApp()
+ }
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ trackingService.checkOptIn(this@MainActivity)
+ }
}
}
-
-}
-
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/Navigation.kt b/app/src/main/java/de/psdev/devdrawer/Navigation.kt
new file mode 100644
index 00000000..65aa6a15
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/Navigation.kt
@@ -0,0 +1,19 @@
+package de.psdev.devdrawer
+
+import androidx.annotation.StringRes
+import de.psdev.devdrawer.destinations.Destination
+import de.psdev.devdrawer.destinations.SettingsScreenDestination
+import de.psdev.devdrawer.destinations.WidgetEditorScreenDestination
+import de.psdev.devdrawer.destinations.WidgetListScreenDestination
+import de.psdev.devdrawer.destinations.WidgetProfileEditorDestination
+import de.psdev.devdrawer.destinations.WidgetProfilesScreenDestination
+
+@get:StringRes
+val Destination.title
+ get(): Int = when (this) {
+ SettingsScreenDestination -> R.string.settings
+ WidgetListScreenDestination -> R.string.widgets
+ WidgetProfilesScreenDestination -> R.string.profiles
+ WidgetEditorScreenDestination -> R.string.edit_widget
+ WidgetProfileEditorDestination -> R.string.edit_profile
+ }
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/NavigationCommand.kt b/app/src/main/java/de/psdev/devdrawer/NavigationCommand.kt
new file mode 100644
index 00000000..052ab359
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/NavigationCommand.kt
@@ -0,0 +1,9 @@
+package de.psdev.devdrawer
+
+import androidx.navigation.NamedNavArgument
+
+interface NavigationCommand {
+ val arguments: List
+ val route: String
+}
+
diff --git a/app/src/main/java/de/psdev/devdrawer/TopBarMenu.kt b/app/src/main/java/de/psdev/devdrawer/TopBarMenu.kt
new file mode 100644
index 00000000..840e870d
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/TopBarMenu.kt
@@ -0,0 +1,25 @@
+package de.psdev.devdrawer
+
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import mu.KotlinLogging
+
+typealias AppBarActionsComposable = (@Composable RowScope.() -> Unit)
+typealias AppBarMenuCallback = (AppBarActionsComposable) -> Unit
+
+private val logger = KotlinLogging.logger { }
+
+@Composable
+fun ProvideMenu(
+ provider: AppBarMenuCallback,
+ key: Any? = null,
+ block: AppBarActionsComposable
+) {
+ logger.warn { "ProvideMenu($provider, $key, $block)" }
+ LaunchedEffect(key1 = key) {
+ logger.warn { "LaunchedEffect($key)" }
+ provider(block)
+ }
+}
+
diff --git a/app/src/main/java/de/psdev/devdrawer/ViewBindingBaseFragment.kt b/app/src/main/java/de/psdev/devdrawer/ViewBindingBaseFragment.kt
new file mode 100644
index 00000000..d5d17130
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/ViewBindingBaseFragment.kt
@@ -0,0 +1,37 @@
+package de.psdev.devdrawer
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.CallSuper
+import androidx.viewbinding.ViewBinding
+
+abstract class ViewBindingBaseFragment : BaseFragment() {
+
+ private var _binding: T? = null
+
+ // This property is only valid between onCreateView and onDestroyView.
+ protected val binding get() = _binding!!
+
+ final override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View = createViewBinding(inflater, container, savedInstanceState).also { viewBinding ->
+ _binding = viewBinding
+ }.root
+
+ protected abstract fun createViewBinding(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): T
+
+ @CallSuper
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+}
diff --git a/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt b/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt
index fa33fa0b..c9a23d94 100644
--- a/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt
+++ b/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt
@@ -9,17 +9,18 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.fragment.app.commit
-import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.LibsBuilder
-import com.mikepenz.aboutlibraries.util.LibsListenerImpl
+import com.mikepenz.aboutlibraries.LibsConfiguration
+import com.mikepenz.aboutlibraries.entity.Library
+import com.mikepenz.aboutlibraries.util.SpecialButton
import dagger.hilt.android.AndroidEntryPoint
-import de.psdev.devdrawer.BaseFragment
import de.psdev.devdrawer.R
+import de.psdev.devdrawer.ViewBindingBaseFragment
import de.psdev.devdrawer.databinding.FragmentAboutBinding
import de.psdev.devdrawer.utils.consume
@AndroidEntryPoint
-class AboutFragment : BaseFragment() {
+class AboutFragment : ViewBindingBaseFragment() {
override fun createViewBinding(
inflater: LayoutInflater,
@@ -33,30 +34,71 @@ class AboutFragment : BaseFragment() {
if (childFragmentManager.findFragmentById(R.id.container_fragment) == null) {
childFragmentManager.commit {
val fragment = LibsBuilder()
- .withFields(R.string::class.java.fields)
- .withListener(object : LibsListenerImpl() {
- override fun onExtraClicked(v: View, specialButton: Libs.SpecialButton): Boolean =
- when (specialButton) {
- Libs.SpecialButton.SPECIAL1 -> consume {
- val intent = Intent(Intent.ACTION_VIEW).apply {
- data =
- "https://play.google.com/store/apps/details?id=de.psdev.devdrawer".toUri()
- setPackage("com.android.vending")
- }
- startActivity(intent)
+ .withListener(object : LibsConfiguration.LibsListener {
+ override fun onExtraClicked(
+ v: View,
+ specialButton: SpecialButton
+ ): Boolean = when (specialButton) {
+ SpecialButton.SPECIAL1 -> consume {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ data =
+ "https://play.google.com/store/apps/details?id=de.psdev.devdrawer".toUri()
+ setPackage("com.android.vending")
}
- Libs.SpecialButton.SPECIAL2 -> consume {
- val customTabsIntent = CustomTabsIntent.Builder().build()
- customTabsIntent.intent.data = "https://github.com/PSDev/DevDrawer".toUri()
- customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- ContextCompat.startActivity(
- view.context.applicationContext,
- customTabsIntent.intent,
- customTabsIntent.startAnimationBundle
- )
- }
- else -> super.onExtraClicked(v, specialButton)
+ startActivity(intent)
+ }
+ SpecialButton.SPECIAL2 -> consume {
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+ customTabsIntent.intent.data = "https://github.com/PSDev/DevDrawer".toUri()
+ customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ ContextCompat.startActivity(
+ view.context.applicationContext,
+ customTabsIntent.intent,
+ customTabsIntent.startAnimationBundle
+ )
}
+ else -> false
+ }
+
+ override fun onIconClicked(v: View) {
+ }
+
+ override fun onIconLongClicked(v: View): Boolean {
+ return false
+ }
+
+ override fun onLibraryAuthorClicked(v: View, library: Library): Boolean {
+ return false
+ }
+
+ override fun onLibraryAuthorLongClicked(
+ v: View,
+ library: Library
+ ): Boolean {
+ return false
+ }
+
+ override fun onLibraryBottomClicked(v: View, library: Library): Boolean {
+ return false
+ }
+
+ override fun onLibraryBottomLongClicked(
+ v: View,
+ library: Library
+ ): Boolean {
+ return false
+ }
+
+ override fun onLibraryContentClicked(v: View, library: Library): Boolean {
+ return false
+ }
+
+ override fun onLibraryContentLongClicked(
+ v: View,
+ library: Library
+ ): Boolean {
+ return false
+ }
})
.supportFragment()
diff --git a/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt b/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt
index cc685f15..6e4855a4 100644
--- a/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt
+++ b/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt
@@ -30,7 +30,7 @@ class TrackingService @Inject constructor(
private val application: Application,
private val remoteConfigService: RemoteConfigService,
) {
- companion object : KLogging() {
+ companion object: KLogging() {
const val PREF_KEY_OPTED_IN = "feature_analytics_opted_in"
const val PREF_KEY_OPTED_IN_TIME = "feature_analytics_opted_in_time"
const val CONFIG_KEY_ENABLED = "feature_analytics_enabled"
diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt
index 3aee1b41..d61ca917 100644
--- a/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt
+++ b/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt
@@ -5,6 +5,7 @@ import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
+import android.os.Build
import androidx.recyclerview.widget.DiffUtil
import mu.KotlinLogging
import okio.HashingSink
@@ -17,12 +18,12 @@ data class AppInfo(
val name: String,
val packageName: String,
val appIcon: Drawable,
- val firstInstalledTime: Long,
+ val firstInstallTime: Long,
val lastUpdateTime: Long,
- val signatureSha256: String
+ val signatureHashSha256: String
) {
companion object {
- val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
+ val DIFF_CALLBACK = object: DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: AppInfo, newItem: AppInfo): Boolean =
oldItem.packageName == newItem.packageName
@@ -47,7 +48,12 @@ val PackageInfo.signatureHashSha256: String
get() {
val hashingSink = HashingSink.sha256(blackholeSink()).use {
it.buffer().use { bufferedSink ->
- bufferedSink.write(signatures.first().toByteArray())
+ val signatureBytes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ signingInfo.apkContentsSigners.first().toByteArray()
+ } else {
+ signatures.first().toByteArray()
+ }
+ bufferedSink.write(signatureBytes)
}
it
}
diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt
index 5e5e9bf7..f1176c2e 100644
--- a/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt
+++ b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt
@@ -6,20 +6,18 @@ import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
-import android.graphics.Color
import android.net.Uri
import android.widget.RemoteViews
import dagger.hilt.android.AndroidEntryPoint
import de.psdev.devdrawer.R
import de.psdev.devdrawer.database.DevDrawerDatabase
import de.psdev.devdrawer.database.Widget
-import de.psdev.devdrawer.database.WidgetProfile
import de.psdev.devdrawer.receivers.UpdateReceiver
-import de.psdev.devdrawer.utils.Constants
import de.psdev.devdrawer.utils.textColorForBackground
-import de.psdev.devdrawer.widgets.WidgetConfigActivity
+import de.psdev.devdrawer.widgets.ui.WidgetConfigActivity
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import mu.KLogging
import java.text.DateFormat
@@ -30,51 +28,26 @@ import javax.inject.Inject
* NOTE: Never rename this as it will break existing widgets.
*/
@AndroidEntryPoint
-class DDWidgetProvider : AppWidgetProvider() {
+class DDWidgetProvider: AppWidgetProvider() {
@Inject
lateinit var devDrawerDatabase: DevDrawerDatabase
- companion object : KLogging()
+ companion object: KLogging()
+
+ private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// ==========================================================================================================================
// AppWidgetProvider
// ==========================================================================================================================
- override fun onReceive(context: Context, intent: Intent) {
- super.onReceive(context, intent)
- when (intent.action) {
- Constants.ACTION_WIDGET_PINNED -> GlobalScope.launch(Dispatchers.IO) {
- val widgetDao = devDrawerDatabase.widgetDao()
- val widgetProfileDao = devDrawerDatabase.widgetProfileDao()
- val defaultWidgetProfile = widgetProfileDao.findAll().firstOrNull()
- ?: WidgetProfile(name = "Default").also {
- widgetProfileDao.insert(it)
- }
- val widgetId = intent.getIntExtra(
- AppWidgetManager.EXTRA_APPWIDGET_ID,
- AppWidgetManager.INVALID_APPWIDGET_ID
- )
-
- // Create entries in database
- val widget = Widget(
- id = widgetId,
- name = "Widget $widgetId",
- color = Color.BLACK,
- profileId = defaultWidgetProfile.id
- )
- widgetDao.insert(widget)
- UpdateReceiver.send(context)
- }
- }
- }
-
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
- GlobalScope.launch(Dispatchers.IO) {
+ coroutineScope.launch {
for (appWidgetId in appWidgetIds) {
val widget = devDrawerDatabase.widgetDao().findById(appWidgetId)
if (widget != null) {
+ logger.info { "Update Widget $appWidgetId" }
updateWidget(context, widget, appWidgetManager)
} else {
logger.warn { "Widget $appWidgetId does not exist" }
@@ -86,12 +59,13 @@ class DDWidgetProvider : AppWidgetProvider() {
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
logger.warn { "Deleted widgets ${appWidgetIds.joinToString()}" }
- GlobalScope.launch(Dispatchers.IO) {
+ coroutineScope.launch {
devDrawerDatabase.widgetDao().deleteByIds(appWidgetIds.toList())
}
}
private fun updateWidget(context: Context, widget: Widget, appWidgetManager: AppWidgetManager) {
+ logger.trace { "updateWidget(widget=$widget)" }
try {
val view = createRemoteViews(context, widget)
appWidgetManager.updateAppWidget(widget.id, view)
@@ -119,15 +93,19 @@ class DDWidgetProvider : AppWidgetProvider() {
context,
0,
Intent(context, UpdateReceiver::class.java),
- PendingIntent.FLAG_UPDATE_CURRENT
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
widgetView.setOnClickPendingIntent(R.id.btn_reload, reloadPendingIntent)
val configActivityIntent = WidgetConfigActivity.createStartIntent(context, widget.id)
configActivityIntent.putExtra("from_widget", true)
configActivityIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK)
- val configActivityPendingIntent =
- PendingIntent.getActivity(context, 0, configActivityIntent, PendingIntent.FLAG_UPDATE_CURRENT)
+ val configActivityPendingIntent = PendingIntent.getActivity(
+ context,
+ 0,
+ configActivityIntent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
widgetView.setOnClickPendingIntent(R.id.btn_settings, configActivityPendingIntent)
// Apps list
@@ -141,7 +119,12 @@ class DDWidgetProvider : AppWidgetProvider() {
val clickIntent = Intent(context, ClickHandlingActivity::class.java).apply {
addFlags(FLAG_ACTIVITY_NEW_TASK)
}
- val clickPI = PendingIntent.getActivity(context, 0, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT)
+ val clickPI = PendingIntent.getActivity(
+ context,
+ 0,
+ clickIntent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
widgetView.setPendingIntentTemplate(R.id.listView, clickPI)
return widgetView
diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt
index 7177a370..c51bb52c 100644
--- a/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt
+++ b/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt
@@ -1,5 +1,6 @@
package de.psdev.devdrawer.appwidget
+import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
data class PackageHashInfo(
@@ -9,4 +10,6 @@ data class PackageHashInfo(
val signatureHashSha256: String
)
-fun PackageInfo.toPackageHashInfo(): PackageHashInfo = PackageHashInfo(packageName, firstInstallTime, lastUpdateTime, signatureHashSha256)
\ No newline at end of file
+fun PackageInfo.toPackageHashInfo(): PackageHashInfo = PackageHashInfo(packageName, firstInstallTime, lastUpdateTime, signatureHashSha256)
+val PackageInfo.isSystemApp: Boolean
+ get() = applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/UpdateWidgetsWorker.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/UpdateWidgetsWorker.kt
index e81cb9a7..55d79816 100644
--- a/app/src/main/java/de/psdev/devdrawer/appwidget/UpdateWidgetsWorker.kt
+++ b/app/src/main/java/de/psdev/devdrawer/appwidget/UpdateWidgetsWorker.kt
@@ -3,13 +3,18 @@ package de.psdev.devdrawer.appwidget
import android.app.Application
import android.content.Context
import androidx.hilt.work.HiltWorker
-import androidx.work.*
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import de.psdev.devdrawer.receivers.UpdateReceiver
import mu.KLogging
import java.util.concurrent.TimeUnit
+
@HiltWorker
class UpdateWidgetsWorker @AssistedInject constructor(
@Assisted appContext: Context,
diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt
index 42450ddb..92ae583b 100644
--- a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt
+++ b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt
@@ -146,7 +146,7 @@ class WidgetAppsListViewFactory(
defaultSortOrder
)
)) {
- SortOrder.FIRST_INSTALLED -> compareByDescending { it.firstInstalledTime }
+ SortOrder.FIRST_INSTALLED -> compareByDescending { it.firstInstallTime }
SortOrder.LAST_UPDATED -> compareByDescending { it.lastUpdateTime }
SortOrder.NAME -> compareBy { it.name }
SortOrder.PACKAGE_NAME -> compareBy { it.packageName }
diff --git a/app/src/main/java/de/psdev/devdrawer/database/BaseDao.kt b/app/src/main/java/de/psdev/devdrawer/database/BaseDao.kt
index becd1eed..fb1b255c 100644
--- a/app/src/main/java/de/psdev/devdrawer/database/BaseDao.kt
+++ b/app/src/main/java/de/psdev/devdrawer/database/BaseDao.kt
@@ -1,10 +1,6 @@
package de.psdev.devdrawer.database
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Transaction
-import androidx.room.Update
+import androidx.room.*
abstract class BaseDao {
diff --git a/app/src/main/java/de/psdev/devdrawer/database/Converters.kt b/app/src/main/java/de/psdev/devdrawer/database/Converters.kt
index 894ea48a..8f344557 100644
--- a/app/src/main/java/de/psdev/devdrawer/database/Converters.kt
+++ b/app/src/main/java/de/psdev/devdrawer/database/Converters.kt
@@ -1,6 +1,7 @@
package de.psdev.devdrawer.database
import androidx.room.TypeConverter
+import java.time.Instant
class Converters {
@TypeConverter
@@ -8,4 +9,10 @@ class Converters {
@TypeConverter
fun toFilterType(value: String?): FilterType? = value?.let { FilterType.valueOf(it) }
+
+ @TypeConverter
+ fun fromOffsetDateTIme(value: Instant?): Long? = value?.toEpochMilli()
+
+ @TypeConverter
+ fun toOffsetDateTime(value: Long?): Instant? = value?.let { Instant.ofEpochMilli(it) }
}
diff --git a/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt b/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt
index 9cb774b8..4d06a85c 100644
--- a/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt
+++ b/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt
@@ -20,6 +20,7 @@ class DatabaseModule {
DevDrawerDatabase.NAME
).apply {
addMigrations(MigrationFrom1To2(application))
+ addMigrations(MigrationFrom2To3)
}.build()
}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt b/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt
index bdac6da9..64c16385 100644
--- a/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt
+++ b/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt
@@ -16,7 +16,7 @@ abstract class DevDrawerDatabase: RoomDatabase() {
companion object {
const val NAME = "DevDrawer.db"
- const val VERSION = 2
+ const val VERSION = 3
}
abstract fun widgetDao(): WidgetDao
diff --git a/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt b/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt
index cc6e7e51..4f8160b5 100644
--- a/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt
+++ b/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt
@@ -7,36 +7,43 @@ import android.graphics.Color
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import de.psdev.devdrawer.appwidget.DDWidgetProvider
-import java.util.*
+import java.util.UUID
class MigrationFrom1To2(
private val application: Application
) : Migration(1, 2) {
private val appWidgetManager by lazy { AppWidgetManager.getInstance(application) }
- override fun migrate(database: SupportSQLiteDatabase) {
+ override fun migrate(db: SupportSQLiteDatabase) {
// Create profiles tables
- database.execSQL("CREATE TABLE IF NOT EXISTS `widget_profiles` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))")
+ db.execSQL("CREATE TABLE IF NOT EXISTS `widget_profiles` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))")
// Insert default profile
val defaultProfileId = UUID.randomUUID().toString()
- database.execSQL("INSERT INTO `widget_profiles` (`id`, `name`) VALUES ('$defaultProfileId', 'Default')")
+ db.execSQL("INSERT INTO `widget_profiles` (`id`, `name`) VALUES ('$defaultProfileId', 'Default')")
// Migrate filters table
- database.execSQL("ALTER TABLE `filters` RENAME TO `filters_old`")
- database.execSQL("CREATE TABLE IF NOT EXISTS `filters` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `filter` TEXT NOT NULL, `description` TEXT NOT NULL, `profile_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
- database.execSQL("INSERT INTO `filters` (`id`, `type`, `filter`, `description`, `profile_id`) SELECT `id`, 'PACKAGE_NAME', `filter`, '', '$defaultProfileId' FROM `filters_old`")
- database.execSQL("CREATE INDEX IF NOT EXISTS `index_filters_profile_id` ON `filters` (`profile_id`)")
- database.execSQL("DROP TABLE `filters_old`")
+ db.execSQL("ALTER TABLE `filters` RENAME TO `filters_old`")
+ db.execSQL("CREATE TABLE IF NOT EXISTS `filters` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `filter` TEXT NOT NULL, `description` TEXT NOT NULL, `profile_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
+ db.execSQL("INSERT INTO `filters` (`id`, `type`, `filter`, `description`, `profile_id`) SELECT `id`, 'PACKAGE_NAME', `filter`, '', '$defaultProfileId' FROM `filters_old`")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_filters_profile_id` ON `filters` (`profile_id`)")
+ db.execSQL("DROP TABLE `filters_old`")
// Create widgets table
- database.execSQL("CREATE TABLE IF NOT EXISTS `widgets` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `profile_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )")
- database.execSQL("CREATE INDEX IF NOT EXISTS `index_widgets_name` ON `widgets` (`name`)")
- database.execSQL("CREATE INDEX IF NOT EXISTS `index_widgets_profile_id` ON `widgets` (`profile_id`)")
+ db.execSQL("CREATE TABLE IF NOT EXISTS `widgets` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `profile_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_widgets_name` ON `widgets` (`name`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_widgets_profile_id` ON `widgets` (`profile_id`)")
// Insert existing widgets
val componentName = ComponentName(application, DDWidgetProvider::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName).toList()
for (appWidgetId in appWidgetIds) {
- database.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES ($appWidgetId, 'Widget $appWidgetId', ${Color.BLACK}, '$defaultProfileId')")
+ db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES ($appWidgetId, 'Widget $appWidgetId', ${Color.BLACK}, '$defaultProfileId')")
}
}
-}
\ No newline at end of file
+}
+
+object MigrationFrom2To3 : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `widget_profiles` ADD COLUMN `updatedAt` INTEGER NOT NULL DEFAULT 0;")
+ db.execSQL("UPDATE `widget_profiles` SET `updatedAt` = ${System.currentTimeMillis()};")
+ }
+}
diff --git a/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt b/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt
index 2d11c61d..029ccd39 100644
--- a/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt
+++ b/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt
@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow
abstract class PackageFilterDao : BaseDao() {
@Query("SELECT * FROM filters WHERE id = :id")
- abstract fun findById(id: String): PackageFilter?
+ abstract suspend fun findById(id: String): PackageFilter?
@Query("SELECT * FROM filters WHERE profile_id = :profileId")
abstract suspend fun findAllByProfile(profileId: String): List
diff --git a/app/src/main/java/de/psdev/devdrawer/database/WidgetDao.kt b/app/src/main/java/de/psdev/devdrawer/database/WidgetDao.kt
index 82cdd972..3e2c0fb4 100644
--- a/app/src/main/java/de/psdev/devdrawer/database/WidgetDao.kt
+++ b/app/src/main/java/de/psdev/devdrawer/database/WidgetDao.kt
@@ -2,10 +2,11 @@ package de.psdev.devdrawer.database
import androidx.room.Dao
import androidx.room.Query
+import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
-abstract class WidgetDao : BaseDao() {
+abstract class WidgetDao: BaseDao() {
@Query("SELECT * FROM widgets")
abstract suspend fun findAll(): List
@@ -16,9 +17,16 @@ abstract class WidgetDao : BaseDao() {
@Query("SELECT * FROM widgets")
abstract fun findAllFlow(): Flow>
+ @Query("SELECT * FROM widgets WHERE profile_id = :profileId")
+ abstract suspend fun findAllByProfileId(profileId: String): List
+
@Query("SELECT * FROM widgets WHERE id = :id")
abstract suspend fun findById(id: Int): Widget?
+ @Transaction
+ @Query("SELECT * FROM widgets WHERE id = :id")
+ abstract fun widgetWithIdObservable(id: Int): Flow
+
@Query("DELETE FROM widgets WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List)
diff --git a/app/src/main/java/de/psdev/devdrawer/database/WidgetProfile.kt b/app/src/main/java/de/psdev/devdrawer/database/WidgetProfile.kt
index 92c4be35..5f989283 100644
--- a/app/src/main/java/de/psdev/devdrawer/database/WidgetProfile.kt
+++ b/app/src/main/java/de/psdev/devdrawer/database/WidgetProfile.kt
@@ -3,8 +3,8 @@ package de.psdev.devdrawer.database
import androidx.recyclerview.widget.DiffUtil
import androidx.room.ColumnInfo
import androidx.room.Entity
-import androidx.room.ForeignKey
import androidx.room.PrimaryKey
+import java.time.Instant
import java.util.*
@Entity(tableName = "widget_profiles")
@@ -13,12 +13,17 @@ data class WidgetProfile(
@ColumnInfo(name = "id")
val id: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "name", typeAffinity = ColumnInfo.TEXT)
- var name: String
+ var name: String,
+ @ColumnInfo(name = "updatedAt")
+ var updatedAt: Instant = Instant.now()
) {
companion object {
- val DIFF_CALLBACK = object: DiffUtil.ItemCallback() {
- override fun areItemsTheSame(oldItem: WidgetProfile, newItem: WidgetProfile): Boolean = oldItem.id == newItem.id
- override fun areContentsTheSame(oldItem: WidgetProfile, newItem: WidgetProfile): Boolean = oldItem == newItem
+ val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: WidgetProfile, newItem: WidgetProfile): Boolean =
+ oldItem.id == newItem.id
+
+ override fun areContentsTheSame(oldItem: WidgetProfile, newItem: WidgetProfile): Boolean =
+ oldItem == newItem
}
}
}
diff --git a/app/src/main/java/de/psdev/devdrawer/database/WidgetProfileDao.kt b/app/src/main/java/de/psdev/devdrawer/database/WidgetProfileDao.kt
index b190ac55..7735b3b9 100644
--- a/app/src/main/java/de/psdev/devdrawer/database/WidgetProfileDao.kt
+++ b/app/src/main/java/de/psdev/devdrawer/database/WidgetProfileDao.kt
@@ -2,10 +2,12 @@ package de.psdev.devdrawer.database
import androidx.room.Dao
import androidx.room.Query
+import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
+import java.time.Instant
@Dao
-abstract class WidgetProfileDao: BaseDao() {
+abstract class WidgetProfileDao : BaseDao() {
@Query("SELECT * FROM widget_profiles")
abstract suspend fun findAll(): List
@@ -15,4 +17,12 @@ abstract class WidgetProfileDao: BaseDao() {
@Query("SELECT * FROM widget_profiles WHERE id = :id")
abstract suspend fun findById(id: String): WidgetProfile?
+ @Transaction
+ @Query("SELECT * FROM widget_profiles WHERE id = :id")
+ abstract fun widgetProfileWithIdObservable(id: String): Flow
+
+ suspend fun updateWithTimestamp(widgetProfile: WidgetProfile) {
+ update(widgetProfile.copy(updatedAt = Instant.now()))
+ }
+
}
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt
index 2969eefc..9da1a1dd 100644
--- a/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt
@@ -12,6 +12,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.firebase.ktx.Firebase
import com.google.firebase.perf.ktx.performance
import dagger.hilt.android.AndroidEntryPoint
+import de.psdev.devdrawer.appwidget.isSystemApp
import de.psdev.devdrawer.appwidget.toAppInfo
import de.psdev.devdrawer.appwidget.toPackageHashInfo
import de.psdev.devdrawer.database.DevDrawerDatabase
@@ -42,7 +43,7 @@ class AppSignatureChooserBottomSheetDialogFragment : BottomSheetDialogFragment()
private val onAppClickListener: AppInfoActionListener = { appInfo ->
lifecycleScope.launch {
val packageFilter = PackageFilter(
- filter = appInfo.signatureSha256,
+ filter = appInfo.signatureHashSha256,
type = FilterType.SIGNATURE,
description = appInfo.name,
profileId = navArgs.widgetProfileId
@@ -76,6 +77,7 @@ class AppSignatureChooserBottomSheetDialogFragment : BottomSheetDialogFragment()
val installedPackages = Firebase.performance.trace("widget_profile_packages") {
packageManager.getInstalledPackages(PackageManager.GET_SIGNATURES)
.asSequence()
+ .filterNot { it.isSystemApp } // TODO Option to allow system apps?
.map { it.toPackageHashInfo() }
.distinctBy { it.signatureHashSha256 }
.filter { hashInfo -> filters.none { it.type == FilterType.SIGNATURE && it.filter == hashInfo.signatureHashSha256 } }
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/AppsService.kt b/app/src/main/java/de/psdev/devdrawer/profiles/AppsService.kt
new file mode 100644
index 00000000..47cc5275
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/AppsService.kt
@@ -0,0 +1,60 @@
+package de.psdev.devdrawer.profiles
+
+import android.app.Application
+import android.content.pm.PackageManager.GET_SIGNATURES
+import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
+import android.os.Build
+import com.google.firebase.ktx.Firebase
+import com.google.firebase.perf.ktx.performance
+import de.psdev.devdrawer.appwidget.*
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.utils.trace
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AppsService @Inject constructor(
+ private val application: Application
+) {
+
+ private val packageManager by lazy { application.packageManager }
+
+ suspend fun getAppsForPackageFilter(
+ packageFilter: PackageFilter
+ ): List = Firebase.performance.trace("getAppsForPackageFilter") {
+ withContext(Dispatchers.IO) {
+ packageManager.getInstalledPackages(getFlags())
+ .asSequence()
+ .map { it.toPackageHashInfo() }
+ .filter { packageFilter.matches(it) }
+ .mapNotNull { it.toAppInfo(application) }
+ .sortedBy { it.name }
+ .toList()
+ }
+ }
+
+ suspend fun getInstalledPackages(systemApps: Boolean = false): List =
+ Firebase.performance.trace("getInstalledPackages") {
+ withContext(Dispatchers.IO) {
+ packageManager.getInstalledPackages(getFlags())
+ .asSequence()
+ .filter {
+ if (systemApps) {
+ true
+ } else !it.isSystemApp
+ }
+ .map { it.toPackageHashInfo() }
+ .toList()
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun getFlags() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ GET_SIGNING_CERTIFICATES
+ } else {
+ GET_SIGNATURES
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt b/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt
new file mode 100644
index 00000000..064a7e27
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt
@@ -0,0 +1,16 @@
+package de.psdev.devdrawer.profiles
+
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.database.WidgetProfile
+
+sealed class DeleteDialogState {
+ data object Hidden : DeleteDialogState()
+ data class Showing(
+ val widgetProfile: WidgetProfile
+ ) : DeleteDialogState()
+
+ data class InUseError(
+ val widgetProfile: WidgetProfile,
+ val widgets: List
+ ) : DeleteDialogState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/FilterPreviewBottomSheetDialogFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/FilterPreviewBottomSheetDialogFragment.kt
index 028f61c2..cde6267c 100644
--- a/app/src/main/java/de/psdev/devdrawer/profiles/FilterPreviewBottomSheetDialogFragment.kt
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/FilterPreviewBottomSheetDialogFragment.kt
@@ -37,7 +37,7 @@ class FilterPreviewBottomSheetDialogFragment : BottomSheetDialogFragment() {
private val onAppClickListener: AppInfoActionListener = { appInfo ->
val activity = requireActivity()
- startActivity(activity.packageManager.getLaunchIntentForPackage(appInfo.packageName))
+ startActivity(activity.packageManager.getLaunchIntentForPackage(appInfo.packageName)!!)
}
private val appAdapter = AppListAdapter(onAppClickListener)
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterListAdapter.kt
deleted file mode 100644
index bbd49014..00000000
--- a/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterListAdapter.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-package de.psdev.devdrawer.profiles
-
-import android.view.ViewGroup
-import androidx.core.view.isVisible
-import androidx.recyclerview.selection.SelectionTracker
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import de.psdev.devdrawer.R
-import de.psdev.devdrawer.database.FilterType
-import de.psdev.devdrawer.database.PackageFilter
-import de.psdev.devdrawer.databinding.ListItemPackageFilterBinding
-import de.psdev.devdrawer.utils.layoutInflater
-
-class PackageFilterListAdapter(
- private val onDeleteClickListener: PackageFilterActionListener,
- private val onPreviewFilterClickListener: PackageFilterActionListener
-) : ListAdapter(PackageFilter.DIFF_CALLBACK) {
-
- var selectionTracker: SelectionTracker? = null
-
- // ==========================================================================================================================
- // ListAdapter
- // ==========================================================================================================================
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PackageFilterViewHolder {
- val onClickListener: PackageFilterActionListener = { packageFilter ->
- selectionTracker?.select(packageFilter.id)
- }
- return PackageFilterViewHolder(
- binding = ListItemPackageFilterBinding.inflate(parent.layoutInflater, parent, false),
- onClickListener = onClickListener,
- onDeleteClickListener = onDeleteClickListener,
- onPreviewFilterClickListener = onPreviewFilterClickListener
- )
- }
-
- override fun onBindViewHolder(holder: PackageFilterViewHolder, position: Int) {
- val packageFilter = getItem(position)
- val isSelected = selectionTracker?.isSelected(packageFilter.id) ?: false
- holder.bindTo(packageFilter, isSelected)
- }
-
- public override fun getItem(position: Int): PackageFilter = super.getItem(position)
-
- class PackageFilterViewHolder(
- private val binding: ListItemPackageFilterBinding,
- private val onClickListener: PackageFilterActionListener,
- private val onDeleteClickListener: PackageFilterActionListener,
- private val onPreviewFilterClickListener: PackageFilterActionListener
- ) : RecyclerView.ViewHolder(binding.root) {
- var currentItem: PackageFilter? = null
- private set
-
- fun bindTo(packageFilter: PackageFilter, isActivated: Boolean = false) {
- currentItem = packageFilter
- with(binding) {
- root.isActivated = isActivated
- root.setOnClickListener {
- onClickListener(packageFilter)
- }
- val iconRes = when (packageFilter.type) {
- FilterType.PACKAGE_NAME -> R.drawable.ic_regex
- FilterType.SIGNATURE -> R.drawable.ic_certificate
- }
- imgIcon.setImageResource(iconRes)
- txtName.text = when (packageFilter.type) {
- FilterType.PACKAGE_NAME -> packageFilter.filter
- FilterType.SIGNATURE -> packageFilter.description
- }
-
- with(btnPreview) {
- setOnClickListener {
- onPreviewFilterClickListener(packageFilter)
- }
- }
- with(btnInfo) {
- isVisible = packageFilter.type == FilterType.SIGNATURE
- setOnClickListener {
- val text = when (packageFilter.type) {
- FilterType.PACKAGE_NAME -> packageFilter.description
- FilterType.SIGNATURE -> "SHA256: ${
- packageFilter.filter.uppercase().chunkedSequence(2)
- .joinToString(separator = ":")
- }"
- }
- MaterialAlertDialogBuilder(itemView.context)
- .setTitle(R.string.info)
- .setMessage(text)
- .setPositiveButton(R.string.close, null)
- .show()
- }
- }
- btnDelete.setOnClickListener {
- onDeleteClickListener(packageFilter)
- }
- }
- }
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt b/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt
new file mode 100644
index 00000000..230fc569
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt
@@ -0,0 +1,28 @@
+package de.psdev.devdrawer.profiles
+
+import android.app.Application
+import de.psdev.devdrawer.database.DevDrawerDatabase
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.receivers.UpdateReceiver
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class PackageFilterRepository @Inject constructor(
+ private val application: Application,
+ private val devDrawerDatabase: DevDrawerDatabase
+) {
+
+ suspend fun getById(packageFilterId: String) = devDrawerDatabase.packageFilterDao().findById(packageFilterId)
+
+ suspend fun delete(packageFilter: PackageFilter) {
+ devDrawerDatabase.packageFilterDao().delete(packageFilter)
+ UpdateReceiver.send(application)
+ }
+
+ suspend fun save(packageFilter: PackageFilter) {
+ devDrawerDatabase.packageFilterDao().insert(packageFilter)
+ UpdateReceiver.send(application)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetInUseErrorAlertDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetInUseErrorAlertDialog.kt
new file mode 100644
index 00000000..3965e8ce
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetInUseErrorAlertDialog.kt
@@ -0,0 +1,31 @@
+package de.psdev.devdrawer.profiles
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import de.psdev.devdrawer.R
+
+@Composable
+fun WidgetInUseErrorAlertDialog(
+ state: DeleteDialogState.InUseError,
+ onDismiss: () -> Unit = {}
+) {
+ AlertDialog(
+ onDismissRequest = { },
+ title = {
+ Text(text = "Error")
+ },
+ text = {
+ Text(text = "The profile ${state.widgetProfile.name} is used by: \n" + state.widgets.joinToString("\n") { it.name })
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ onDismiss()
+ }) {
+ Text(stringResource(id = R.string.close))
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt
new file mode 100644
index 00000000..277a2305
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt
@@ -0,0 +1,76 @@
+package de.psdev.devdrawer.profiles
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.database.WidgetProfile
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import de.psdev.devdrawer.utils.DefaultPreviews
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun WidgetProfileCard(
+ widgetProfile: WidgetProfile,
+ onWidgetProfileClick: (WidgetProfile) -> Unit = {},
+ onWidgetProfileLongClick: (WidgetProfile) -> Unit = {}
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .padding(8.dp)
+ .combinedClickable(
+ onClick = { onWidgetProfileClick(widgetProfile) },
+ onLongClick = { onWidgetProfileLongClick(widgetProfile) }
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .padding(16.dp)
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ style = MaterialTheme.typography.bodyMedium,
+ text = widgetProfile.name
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Filled.Schedule,
+ contentDescription = stringResource(id = R.string.last_modified)
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ style = MaterialTheme.typography.bodySmall,
+ text = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
+ .format(widgetProfile.updatedAt.atZone(ZoneId.systemDefault()))
+ )
+ }
+ }
+ }
+}
+
+@DefaultPreviews
+@Composable
+fun Preview_WidgetProfileCard() {
+ DevDrawerTheme {
+ WidgetProfileCard(widgetProfile = WidgetProfile(name = "Test profile"))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileDirections.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileDirections.kt
new file mode 100644
index 00000000..eac9a70c
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileDirections.kt
@@ -0,0 +1,28 @@
+package de.psdev.devdrawer.profiles
+
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavType
+import androidx.navigation.navArgument
+import de.psdev.devdrawer.NavigationCommand
+
+object WidgetProfileDirections {
+ val root = object : NavigationCommand {
+ override val arguments: List = emptyList()
+ override val route: String = "profiles"
+ }
+ val list = object : NavigationCommand {
+ override val arguments: List = emptyList()
+ override val route: String = "list"
+ }
+ val edit = object : NavigationCommand {
+ override val arguments: List = listOf(
+ navArgument(
+ name = "profileId"
+ ) {
+ type = NavType.StringType
+ nullable = false
+ }
+ )
+ override val route: String = "profiles/{profileId}"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditFragment.kt
deleted file mode 100644
index 2d3d1614..00000000
--- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditFragment.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-package de.psdev.devdrawer.profiles
-
-import android.database.sqlite.SQLiteConstraintException
-import android.os.Bundle
-import android.view.*
-import androidx.core.view.isVisible
-import androidx.lifecycle.lifecycleScope
-import androidx.navigation.fragment.findNavController
-import androidx.navigation.fragment.navArgs
-import androidx.recyclerview.widget.LinearLayoutManager
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.snackbar.Snackbar
-import dagger.hilt.android.AndroidEntryPoint
-import de.psdev.devdrawer.BaseFragment
-import de.psdev.devdrawer.R
-import de.psdev.devdrawer.database.DevDrawerDatabase
-import de.psdev.devdrawer.database.WidgetProfile
-import de.psdev.devdrawer.databinding.FragmentWidgetProfileEditBinding
-import de.psdev.devdrawer.receivers.UpdateReceiver
-import de.psdev.devdrawer.utils.awaitSubmit
-import de.psdev.devdrawer.utils.consume
-import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.launch
-import mu.KLogging
-import reactivecircus.flowbinding.android.view.clicks
-import reactivecircus.flowbinding.android.widget.textChanges
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class WidgetProfileEditFragment : BaseFragment() {
-
- companion object : KLogging()
-
- @Inject
- lateinit var devDrawerDatabase: DevDrawerDatabase
-
- private val args by navArgs()
-
- private val onDeleteClickListener: PackageFilterActionListener = { packageFilter ->
- MaterialAlertDialogBuilder(requireContext())
- .setTitle("Delete?")
- .setNegativeButton(R.string.no) { _, _ -> }
- .setPositiveButton(R.string.yes) { _, _ ->
- lifecycleScope.launchWhenResumed {
- devDrawerDatabase.packageFilterDao().deleteById(packageFilter.id)
- UpdateReceiver.send(requireContext())
- }
- }
- .show()
- }
- private val onPreviewFilterClickListener: PackageFilterActionListener = { packageFilter ->
- findNavController().navigate(
- WidgetProfileEditFragmentDirections.openFilterPreviewBottomSheetDialogFragment(
- packageFilterId = packageFilter.id
- )
- )
- }
- private val listAdapter: PackageFilterListAdapter = PackageFilterListAdapter(
- onDeleteClickListener = onDeleteClickListener,
- onPreviewFilterClickListener = onPreviewFilterClickListener
- )
- private var widgetProfile: WidgetProfile? = null
-
- private var changedWidgetProfileProperty: MutableStateFlow = MutableStateFlow(false)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun createViewBinding(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): FragmentWidgetProfileEditBinding = FragmentWidgetProfileEditBinding.inflate(inflater, container, false)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- with(binding) {
- val context = requireContext()
-
- btnAddFilter.setOnClickListener { _ ->
- widgetProfile?.let {
- val directions =
- WidgetProfileEditFragmentDirections.openAddPackageFilterBottomSheetDialogFragment(
- widgetProfileId = it.id
- )
- findNavController().navigate(directions)
- }
- }
-
- btnAddSignature.setOnClickListener {
- widgetProfile?.let {
- val directions =
- WidgetProfileEditFragmentDirections.openAppSignatureChooserBottomSheetDialogFragment(
- widgetProfileId = it.id
- )
- findNavController().navigate(directions)
- }
- }
- editName.textChanges().skipInitialValue().map { it.toString() }.onEach {
- widgetProfile?.let { widgetProfile ->
- if (widgetProfile.name != it) {
- widgetProfile.name = it
- changedWidgetProfileProperty.value = true
- }
- }
- }.launchIn(viewLifecycleScope)
-
- changedWidgetProfileProperty.onEach {
- btnApply.isVisible = it
- }.launchIn(viewLifecycleScope)
-
- btnApply.clicks().mapNotNull { widgetProfile }.onEach {
- devDrawerDatabase.widgetProfileDao().insertOrUpdate(it)
- editName.clearFocus()
- changedWidgetProfileProperty.value = false
- }.launchIn(viewLifecycleScope)
-
- recyclerPackages.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
- recyclerPackages.adapter = listAdapter
- }
-
- lifecycleScope.launchWhenResumed {
- val profile = devDrawerDatabase.widgetProfileDao().findById(args.profileId)!!
- binding.editName.setText(profile.name)
- widgetProfile = profile
-
- }
- devDrawerDatabase.packageFilterDao().findAllByProfileFlow(args.profileId).onEach {
- listAdapter.awaitSubmit(it)
- }.launchIn(viewLifecycleScope)
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- inflater.inflate(R.menu.menu_fragment_widget_profile_edit, menu)
- }
-
- override fun onResume() {
- super.onResume()
- updateToolbarTitle(R.string.edit_profile)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
- R.id.action_delete -> consume {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle("Delete profile?")
- .setNegativeButton(R.string.no) { _, _ -> }
- .setPositiveButton(R.string.yes) { _, _ ->
- widgetProfile?.let { widgetProfile ->
- lifecycleScope.launch {
- try {
- devDrawerDatabase.widgetProfileDao().delete(widgetProfile)
- UpdateReceiver.send(requireContext())
- findNavController().popBackStack()
- } catch (e: SQLiteConstraintException) {
- Snackbar.make(binding.root, R.string.error_profile_in_use, Snackbar.LENGTH_LONG).show()
- }
- }
- }
- }
- .show()
- }
- else -> super.onOptionsItemSelected(item)
- }
-
- override fun onDestroyView() {
- binding.recyclerPackages.adapter = null
- super.onDestroyView()
- }
-
-}
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileList.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileList.kt
new file mode 100644
index 00000000..9f8e5e83
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileList.kt
@@ -0,0 +1,48 @@
+package de.psdev.devdrawer.profiles
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import de.psdev.devdrawer.database.WidgetProfile
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+fun WidgetProfileList(
+ widgetProfiles: List,
+ modifier: Modifier = Modifier,
+ onWidgetProfileClick: (WidgetProfile) -> Unit = {},
+ onWidgetProfileLongClick: (WidgetProfile) -> Unit = {}
+) {
+ LazyColumn(
+ modifier = modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ ) {
+ items(widgetProfiles, key = { it.id }) { widgetProfile ->
+ WidgetProfileCard(
+ widgetProfile = widgetProfile,
+ onWidgetProfileClick = onWidgetProfileClick,
+ onWidgetProfileLongClick = onWidgetProfileLongClick
+ )
+ }
+ }
+}
+
+@Preview
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetProfileList() {
+ DevDrawerTheme {
+ WidgetProfileList(
+ listOf(
+ WidgetProfile(name = "Profile 1"),
+ WidgetProfile(name = "Profile 2")
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt
deleted file mode 100644
index 769ee55f..00000000
--- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt
+++ /dev/null
@@ -1,142 +0,0 @@
-package de.psdev.devdrawer.profiles
-
-import android.database.sqlite.SQLiteConstraintException
-import android.os.Bundle
-import android.view.*
-import androidx.lifecycle.lifecycleScope
-import androidx.navigation.fragment.findNavController
-import androidx.recyclerview.selection.SelectionPredicates
-import androidx.recyclerview.selection.SelectionTracker
-import androidx.recyclerview.selection.StorageStrategy
-import com.google.android.material.snackbar.Snackbar
-import dagger.hilt.android.AndroidEntryPoint
-import de.psdev.devdrawer.BaseFragment
-import de.psdev.devdrawer.R
-import de.psdev.devdrawer.database.DevDrawerDatabase
-import de.psdev.devdrawer.database.WidgetProfile
-import de.psdev.devdrawer.databinding.FragmentWidgetProfileListBinding
-import de.psdev.devdrawer.utils.awaitSubmit
-import de.psdev.devdrawer.utils.consume
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
-import mu.KLogging
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class WidgetProfileListFragment: BaseFragment() {
-
- companion object: KLogging()
-
- // Dependencies
- @Inject
- lateinit var devDrawerDatabase: DevDrawerDatabase
-
- val listAdapter: WidgetProfilesListAdapter = WidgetProfilesListAdapter()
- var _selectionTracker: SelectionTracker? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun createViewBinding(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): FragmentWidgetProfileListBinding =
- FragmentWidgetProfileListBinding.inflate(inflater, container, false)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.recyclerProfiles.adapter = listAdapter
- val selectionTracker = SelectionTracker.Builder(
- "widgetProfile",
- binding.recyclerProfiles,
- WidgetProfilesItemKeyProvider(listAdapter),
- WidgetProfilesDetailsLookup(binding.recyclerProfiles),
- StorageStrategy.createStringStorage()
- ).withSelectionPredicate(SelectionPredicates.createSelectSingleAnything()).build().also { tracker ->
- tracker.onRestoreInstanceState(savedInstanceState)
- tracker.addObserver(object: SelectionTracker.SelectionObserver() {
- override fun onSelectionChanged() {
- super.onSelectionChanged()
- activity?.invalidateOptionsMenu()
- }
- })
- _selectionTracker = tracker
- }
- listAdapter.selectionTracker = selectionTracker
- viewLifecycleOwner.lifecycleScope.launch {
- val widgetProfileDao = devDrawerDatabase.widgetProfileDao()
- widgetProfileDao.findAllFlow().collect {
- logger.warn { "$it" }
- listAdapter.awaitSubmit(it)
- binding.recyclerProfiles.scrollToPosition(it.indexOfFirst { selectionTracker.isSelected(it.id) })
- }
- }
- childFragmentManager.setFragmentResultListener("createProfile", viewLifecycleOwner) { _, bundle ->
- // We use a String here, but any type that can be put in a Bundle is supported
- val result = bundle.getString("profileId") ?: selectionTracker.selection.firstOrNull() ?: ""
- selectionTracker.select(result)
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- inflater.inflate(R.menu.menu_profiles_list, menu)
- val hasSelection = _selectionTracker?.hasSelection() ?: false
- menu.findItem(R.id.action_create).isVisible = !hasSelection
- menu.findItem(R.id.action_edit).isVisible = hasSelection
- menu.findItem(R.id.action_delete).isVisible = hasSelection
- }
-
- override fun onResume() {
- super.onResume()
- updateToolbarTitle(R.string.profiles)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
- R.id.action_create -> consume {
- lifecycleScope.launchWhenResumed {
- val widgetProfileDao = devDrawerDatabase.widgetProfileDao()
- val size = widgetProfileDao.findAll().size
- val widgetProfile = WidgetProfile(name = "Profile ${size + 1}")
- widgetProfileDao.insert(widgetProfile)
- findNavController().navigate(WidgetProfileListFragmentDirections.editWidgetProfile(widgetProfile.id))
- }
- }
- R.id.action_edit -> consume {
- val selectedId = _selectionTracker?.selection?.firstOrNull()
- if (selectedId != null) {
- findNavController().navigate(WidgetProfileListFragmentDirections.editWidgetProfile(selectedId))
- }
- }
- R.id.action_delete -> consume {
- lifecycleScope.launchWhenStarted {
- _selectionTracker?.let { tracker ->
- val selectedProfile = tracker.selection.firstOrNull()
- if (selectedProfile != null) {
- val widgetProfile = devDrawerDatabase.widgetProfileDao().findById(selectedProfile)
- if (widgetProfile != null) {
- try {
- devDrawerDatabase.widgetProfileDao().delete(widgetProfile)
- } catch (e: SQLiteConstraintException) {
- Snackbar.make(binding.root, R.string.error_profile_in_use, Snackbar.LENGTH_LONG).show()
- }
- }
- tracker.deselect(selectedProfile)
- }
- }
- }
- }
- else -> super.onOptionsItemSelected(item)
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(outState)
- _selectionTracker?.onSaveInstanceState(outState)
- }
-
- override fun onDestroyView() {
- _selectionTracker = null
- listAdapter.selectionTracker = null
- binding.recyclerProfiles.adapter = null
- super.onDestroyView()
- }
-}
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileNavGraph.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileNavGraph.kt
new file mode 100644
index 00000000..e49b79b9
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileNavGraph.kt
@@ -0,0 +1,31 @@
+package de.psdev.devdrawer.profiles
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.navigation
+import de.psdev.devdrawer.ProfileEditorDestination
+import de.psdev.devdrawer.profiles.ui.editor.WidgetProfileEditor
+import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesScreen
+
+fun NavGraphBuilder.widgetProfileGraph(navController: NavController) {
+ navigation(
+ route = WidgetProfileDirections.root.route,
+ startDestination = WidgetProfileDirections.list.route
+ ) {
+ composable(
+ route = WidgetProfileDirections.list.route,
+ arguments = WidgetProfileDirections.list.arguments
+ ) {
+ WidgetProfilesScreen(
+ editProfile = { navController.navigate(ProfileEditorDestination(it).route) }
+ )
+ }
+ composable(
+ route = WidgetProfileDirections.edit.route,
+ arguments = WidgetProfileDirections.edit.arguments
+ ) {
+ WidgetProfileEditor()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileRepository.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileRepository.kt
new file mode 100644
index 00000000..b07bc633
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileRepository.kt
@@ -0,0 +1,24 @@
+package de.psdev.devdrawer.profiles
+
+import de.psdev.devdrawer.database.DevDrawerDatabase
+import de.psdev.devdrawer.database.WidgetProfile
+import kotlinx.coroutines.flow.distinctUntilChanged
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WidgetProfileRepository @Inject constructor(
+ private val devDrawerDatabase: DevDrawerDatabase
+) {
+
+ fun widgetProfilesFlow() = devDrawerDatabase.widgetProfileDao().findAllFlow().distinctUntilChanged()
+ suspend fun delete(widgetProfile: WidgetProfile) {
+ devDrawerDatabase.widgetProfileDao().delete(widgetProfile)
+ }
+
+ suspend fun findAll(): List = devDrawerDatabase.widgetProfileDao().findAll()
+ suspend fun create(widgetProfile: WidgetProfile) {
+ devDrawerDatabase.widgetProfileDao().insert(widgetProfile)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesDetailsLookup.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesDetailsLookup.kt
index a321bae2..fa19b009 100644
--- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesDetailsLookup.kt
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesDetailsLookup.kt
@@ -3,6 +3,7 @@ package de.psdev.devdrawer.profiles
import android.view.MotionEvent
import androidx.recyclerview.selection.ItemDetailsLookup
import androidx.recyclerview.widget.RecyclerView
+import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesListAdapter
class WidgetProfilesDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup() {
override fun getItemDetails(event: MotionEvent): ItemDetails? {
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt
index 392d9881..6b7b21fa 100644
--- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt
@@ -1,6 +1,7 @@
package de.psdev.devdrawer.profiles
import androidx.recyclerview.selection.ItemKeyProvider
+import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesListAdapter
class WidgetProfilesItemKeyProvider(private val adapter: WidgetProfilesListAdapter): ItemKeyProvider(
SCOPE_MAPPED
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt
new file mode 100644
index 00000000..8ac1b371
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt
@@ -0,0 +1,48 @@
+package de.psdev.devdrawer.profiles
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.psdev.devdrawer.database.DevDrawerDatabase
+import de.psdev.devdrawer.database.WidgetProfile
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class WidgetProfilesViewModel @Inject constructor(
+ private val widgetProfileRepository: WidgetProfileRepository,
+ val devDrawerDatabase: DevDrawerDatabase
+): ViewModel() {
+
+ val viewState = MutableStateFlow(ViewState.Loading)
+
+ init {
+ viewModelScope.launch {
+ widgetProfileRepository.widgetProfilesFlow().collect {
+ delay(100)
+ viewState.value = ViewState.Loaded(it)
+ }
+ }
+ }
+
+ suspend fun deleteProfile(widgetProfile: WidgetProfile) {
+ widgetProfileRepository.delete(widgetProfile)
+ }
+
+ suspend fun createNewProfile(): WidgetProfile {
+ val size = widgetProfileRepository.findAll().size
+ val widgetProfile = WidgetProfile(name = "Profile ${size + 1}")
+ widgetProfileRepository.create(widgetProfile)
+ return widgetProfile
+ }
+
+ sealed class ViewState {
+ data object Loading : ViewState()
+ data class Loaded(
+ val data: List
+ ): ViewState()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt
new file mode 100644
index 00000000..cf1902eb
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt
@@ -0,0 +1,171 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.SettingsApplications
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.text.toUpperCase
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.appwidget.AppInfo
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.profiles.ui.editor.AddAppSignaturePackageFilterDialogViewModel.ViewState
+import de.psdev.devdrawer.ui.dialog.DefaultDialog
+import de.psdev.devdrawer.ui.loading.LoadingView
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+fun AddAppSignaturePackageFilterDialog(
+ currentFilters: List,
+ viewModel: AddAppSignaturePackageFilterDialogViewModel = hiltViewModel(),
+ closeDialog: () -> Unit = {},
+ appSelected: (AppInfo) -> Unit = {}
+) {
+ val viewState by remember(viewModel) { viewModel.availableApps(currentFilters) }
+ .collectAsState(initial = ViewState.Loading)
+ AddAppSignaturePackageFilterDialog(
+ viewState = viewState,
+ closeDialog = closeDialog,
+ appSelected = appSelected,
+ showSystemApps = { viewModel.showSystemApps.value = it }
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AddAppSignaturePackageFilterDialog(
+ viewState: ViewState,
+ closeDialog: () -> Unit = {},
+ appSelected: (AppInfo) -> Unit = {},
+ showSystemApps: (Boolean) -> Unit = {}
+) {
+ DefaultDialog(
+ onDismissRequest = closeDialog,
+ titleContent = {
+ Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_certificate),
+ contentDescription = stringResource(id = R.string.app_signature)
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ Text(modifier = Modifier.weight(1f), text = stringResource(id = R.string.select_signature_from_app))
+ PlainTooltipBox(tooltip = { Text(text = "Toggle system apps") }) {
+ IconButton(
+ modifier = Modifier.tooltipAnchor(),
+ enabled = viewState is ViewState.Loaded,
+ onClick = {
+ if (viewState is ViewState.Loaded) {
+ showSystemApps(!viewState.showSystemApps)
+ }
+ }) {
+ Icon(
+ imageVector = Icons.Filled.SettingsApplications,
+ contentDescription = "Include system apps"
+ )
+ }
+ }
+ }
+ },
+ bottomContent = {
+ TextButton(
+ modifier = Modifier
+ .align(Alignment.End), onClick = closeDialog
+ ) {
+ Text(text = stringResource(id = R.string.cancel).toUpperCase(Locale.current))
+ }
+ }
+ ) {
+ when (viewState) {
+ ViewState.Loading -> LoadingView(
+ modifier = Modifier
+ .align(CenterHorizontally)
+ .wrapContentHeight(),
+ showText = false
+ )
+
+ is ViewState.Loaded -> {
+ if (viewState.data.isEmpty()) {
+ Text(
+ modifier = Modifier.padding(8.dp),
+ text = stringResource(id = R.string.no_apps_available)
+ )
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f, false)
+ ) {
+ items(viewState.data) {
+ AppInfoItem(appInfo = it, onAppClicked = appSelected)
+ }
+ }
+ }
+ }
+
+ is ViewState.Error -> Text(text = "Error: ${viewState.message}")
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_AddAppSignaturePackageFilterDialog() {
+ DevDrawerTheme {
+ Surface {
+ AddAppSignaturePackageFilterDialog(
+ viewState = ViewState.Loaded(
+ emptyList(),
+ false
+ )
+ )
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_AddAppSignaturePackageFilterDialog_Apps() {
+ DevDrawerTheme {
+ Surface {
+ val bitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)
+ AddAppSignaturePackageFilterDialog(
+ viewState = ViewState.Loaded(
+ listOf(
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ AppInfo(name = "App 1", packageName = "com.example.app1", BitmapDrawable(bitmap), 0, 0, ""),
+ ),
+ false
+ )
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialogViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialogViewModel.kt
new file mode 100644
index 00000000..b200d066
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialogViewModel.kt
@@ -0,0 +1,46 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import android.app.Application
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.psdev.devdrawer.appwidget.AppInfo
+import de.psdev.devdrawer.appwidget.toAppInfo
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.profiles.AppsService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.transformLatest
+import javax.inject.Inject
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@HiltViewModel
+class AddAppSignaturePackageFilterDialogViewModel @Inject constructor(
+ private val application: Application,
+ private val appsService: AppsService
+) : ViewModel() {
+
+ val showSystemApps = MutableStateFlow(false)
+
+ fun availableApps(currentFilters: List) =
+ showSystemApps.transformLatest { showSystemApps ->
+ emit(ViewState.Loading)
+ val availableApps = appsService.getInstalledPackages(showSystemApps)
+ .filter { currentFilters.none { packageFilter -> packageFilter.matches(it) } }
+ .mapNotNull { it.toAppInfo(application) }
+ .sortedBy { it.name }
+ emit(ViewState.Loaded(availableApps, showSystemApps))
+ }.flowOn(Dispatchers.IO)
+
+ sealed class ViewState {
+ data object Loading : ViewState()
+ data class Loaded(
+ val data: List,
+ val showSystemApps: Boolean
+ ) : ViewState()
+
+ data class Error(val message: String) : ViewState()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialog.kt
new file mode 100644
index 00000000..add42ef0
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialog.kt
@@ -0,0 +1,117 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.hilt.navigation.compose.hiltViewModel
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel.ViewState.*
+import de.psdev.devdrawer.ui.autocomplete.AutoCompleteTextView
+import de.psdev.devdrawer.ui.loading.LoadingView
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import java.util.*
+
+@Composable
+fun AddPackageNamePackageFilterDialog(
+ currentFilters: List,
+ viewModel: AddPackageNamePackageFilterDialogViewModel = hiltViewModel(),
+ closeDialog: () -> Unit = {},
+ addFilter: (String) -> Unit = {}
+) {
+ val viewState by remember(viewModel) { viewModel.availablePackageFilters(currentFilters) }
+ .collectAsState(initial = Loading)
+ AddPackageNamePackageFilterDialog(
+ viewState = viewState,
+ closeDialog = closeDialog,
+ addFilter = addFilter
+ )
+}
+
+@Composable
+private fun AddPackageNamePackageFilterDialog(
+ viewState: AddPackageNamePackageFilterDialogViewModel.ViewState,
+ closeDialog: () -> Unit = {},
+ addFilter: (String) -> Unit = {}
+) {
+ Dialog(
+ onDismissRequest = closeDialog,
+ properties = DialogProperties(dismissOnClickOutside = false)
+ ) {
+ Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.surface, shape = MaterialTheme.shapes.medium) {
+ Column(modifier = Modifier.padding(8.dp)) {
+ Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(painter = painterResource(id = R.drawable.ic_certificate), contentDescription = stringResource(id = R.string.app_signature))
+ Spacer(modifier = Modifier.size(4.dp))
+ Text(modifier = Modifier.weight(1f), text = stringResource(id = R.string.enter_package_name_filter))
+ }
+ Spacer(modifier = Modifier.size(4.dp))
+ Divider()
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
+ ) {
+ when (viewState) {
+ Loading -> LoadingView(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(16.dp), showText = false
+ )
+ is Loaded -> {
+ Column {
+ var text by remember { mutableStateOf("") }
+ AutoCompleteTextView(
+ options = viewState.data,
+ label = { Text(text = stringResource(id = R.string.packagefilter)) },
+ onTextChanged = { text = it }
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = closeDialog) {
+ Text(text = stringResource(id = R.string.cancel).uppercase(Locale.getDefault()))
+ }
+ TextButton(onClick = { addFilter(text) }, enabled = text.isNotBlank()) {
+ Text(text = stringResource(id = R.string.add).uppercase(Locale.getDefault()))
+ }
+ }
+ }
+ }
+ is Error -> Text(text = "Error: ${viewState.message}")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_AddPackageNamePackageFilterDialog() {
+ DevDrawerTheme {
+ Surface {
+ AddPackageNamePackageFilterDialog(
+ viewState = Loaded(
+ listOf(
+ "com.example.1",
+ "com.example.2",
+ "com.example.3",
+ "com.example.4",
+ "com.example.5",
+ "com.example.6",
+ )
+ )
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialogViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialogViewModel.kt
new file mode 100644
index 00000000..ea7073ee
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialogViewModel.kt
@@ -0,0 +1,49 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.profiles.AppsService
+import kotlinx.coroutines.flow.flow
+import java.text.Collator
+import javax.inject.Inject
+
+@HiltViewModel
+class AddPackageNamePackageFilterDialogViewModel @Inject constructor(
+ private val appsService: AppsService
+): ViewModel() {
+
+ fun availablePackageFilters(currentFilters: List) = flow {
+ val packageNameFilters = appsService.getInstalledPackages()
+ .filter { currentFilters.none { packageFilter -> packageFilter.matches(it) } }
+ .map { it.packageName }
+ .splitIntoFilters()
+ emit(ViewState.Loaded(packageNameFilters))
+ }
+
+ private fun List.splitIntoFilters(): List {
+ val appSet = mutableSetOf()
+ forEach { packageName ->
+ var tempPackageName = packageName
+ appSet.add(tempPackageName)
+ while (tempPackageName.isNotEmpty()) {
+ val lastIndex = tempPackageName.lastIndexOf(".")
+ if (lastIndex > 0) {
+ tempPackageName = tempPackageName.substring(0, lastIndex)
+ appSet.add("$tempPackageName.*")
+ } else {
+ tempPackageName = ""
+ }
+ }
+ }
+ return appSet.toList().sortedWith(Collator.getInstance())
+ }
+
+ sealed class ViewState {
+ data object Loading : ViewState()
+
+ data class Loaded(val data: List): ViewState()
+ data class Error(val message: String): ViewState()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt
new file mode 100644
index 00000000..beadda6a
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt
@@ -0,0 +1,86 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import android.content.res.Configuration
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+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.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.graphics.drawable.toBitmap
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.appwidget.AppInfo
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+fun AppInfoItem(
+ appInfo: AppInfo,
+ onAppClicked: (AppInfo) -> Unit = {}
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 48.dp)
+ .clickable {
+ onAppClicked(appInfo)
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Image(
+ modifier = Modifier
+ .size(64.dp)
+ .padding(8.dp),
+ bitmap = appInfo.appIcon.toBitmap().asImageBitmap(),
+ contentDescription = "App icon"
+ )
+ Column(modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()) {
+ Text(modifier = Modifier.fillMaxWidth(), text = appInfo.name, style = MaterialTheme.typography.bodyMedium)
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = appInfo.packageName,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_AppInfoItem() {
+ val context = LocalContext.current
+ val resources = context.resources
+ DevDrawerTheme {
+ Surface {
+ AppInfoItem(
+ appInfo = AppInfo(
+ name = "Test app",
+ packageName = "com.example.app",
+ appIcon = ResourcesCompat.getDrawable(
+ resources,
+ R.drawable.ic_launcher_foreground,
+ context.theme
+ )!!,
+ firstInstallTime = System.currentTimeMillis(),
+ lastUpdateTime = System.currentTimeMillis(),
+ signatureHashSha256 = "1234"
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt
new file mode 100644
index 00000000..44c2ba68
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt
@@ -0,0 +1,33 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import de.psdev.devdrawer.R
+import java.util.*
+
+@Composable
+fun DeletePackageFilterDialog(
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(text = stringResource(id = R.string.delete_profile))
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(text = stringResource(id = R.string.cancel).uppercase(Locale.getDefault()))
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text(text = stringResource(id = R.string.yes).uppercase(Locale.getDefault()))
+ }
+ }
+ )
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt
new file mode 100644
index 00000000..6b2bbd25
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt
@@ -0,0 +1,118 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.content.res.ResourcesCompat
+import androidx.hilt.navigation.compose.hiltViewModel
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.appwidget.AppInfo
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel.ViewState.Error
+import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel.ViewState.Loaded
+import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel.ViewState.Loading
+import de.psdev.devdrawer.ui.dialog.DefaultDialog
+import de.psdev.devdrawer.ui.loading.LoadingView
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import java.util.Locale
+
+@Composable
+fun PackageFilterPreviewDialog(
+ packageFilter: PackageFilter,
+ viewModel: PackageFilterPreviewDialogViewModel = hiltViewModel(),
+ closeDialog: () -> Unit = {}
+) {
+ val viewState by remember(viewModel) { viewModel.load(packageFilter) }.collectAsState(initial = Loading)
+ PackageFilterPreviewDialog(
+ viewState = viewState, closeDialog = closeDialog
+ )
+}
+
+@Composable
+private fun PackageFilterPreviewDialog(
+ viewState: PackageFilterPreviewDialogViewModel.ViewState,
+ closeDialog: () -> Unit = {}
+) {
+ DefaultDialog(
+ onDismissRequest = closeDialog,
+ titleContent = {
+ Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_certificate),
+ contentDescription = stringResource(id = R.string.app_signature)
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ Text(modifier = Modifier.weight(1f), text = stringResource(id = R.string.apps_matching_filter))
+ }
+ },
+ bottomContent = {
+ TextButton(modifier = Modifier.align(Alignment.End), onClick = closeDialog) {
+ Text(text = stringResource(id = R.string.close).uppercase(Locale.getDefault()))
+ }
+ }
+ ) {
+ when (viewState) {
+ Loading -> LoadingView(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(16.dp),
+ showText = false
+ )
+
+ is Loaded -> LazyColumn(modifier = Modifier.weight(1f, false)) {
+ items(viewState.data) { appInfo ->
+ AppInfoItem(appInfo = appInfo)
+ }
+ }
+
+ is Error -> Text(text = "Error: ${viewState.message}")
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_PackageFilterPreviewDialog() {
+ val context = LocalContext.current
+ val resources = context.resources
+ DevDrawerTheme {
+ Surface {
+ val baseAppInfo = AppInfo(
+ name = "Test app",
+ packageName = "Test package",
+ appIcon = ResourcesCompat.getDrawable(resources, R.drawable.ic_launcher_foreground, context.theme)!!,
+ firstInstallTime = System.currentTimeMillis(),
+ lastUpdateTime = System.currentTimeMillis(),
+ signatureHashSha256 = "1234"
+ )
+ PackageFilterPreviewDialog(
+ viewState = Loaded(
+ listOf(
+ baseAppInfo,
+ baseAppInfo.copy(name = "App 2"),
+ )
+ )
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialogViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialogViewModel.kt
new file mode 100644
index 00000000..ab0faeb3
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialogViewModel.kt
@@ -0,0 +1,31 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.psdev.devdrawer.appwidget.AppInfo
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.profiles.AppsService
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+@HiltViewModel
+class PackageFilterPreviewDialogViewModel @Inject constructor(
+ private val appsService: AppsService
+) : ViewModel() {
+
+ fun load(packageFilter: PackageFilter) = flow {
+ try {
+ val appsForPackageFilter = appsService.getAppsForPackageFilter(packageFilter)
+ emit(ViewState.Loaded(appsForPackageFilter))
+ } catch (e: Exception) {
+ emit(ViewState.Error(e.message.orEmpty()))
+ }
+ }
+
+ sealed class ViewState {
+ data object Loading : ViewState()
+ data class Loaded(val data: List) : ViewState()
+ data class Error(val message: String) : ViewState()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt
new file mode 100644
index 00000000..650002ba
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt
@@ -0,0 +1,376 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import android.content.res.Configuration
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Preview
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.Save
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.ramcosta.composedestinations.annotation.Destination
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.database.FilterType
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.database.WidgetProfile
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import kotlinx.coroutines.launch
+import java.util.*
+
+data class WidgetProfileEditorArgs(
+ val profileId: String
+)
+
+@Destination(
+ navArgsDelegate = WidgetProfileEditorArgs::class
+)
+@Composable
+fun WidgetProfileEditor(
+ viewModel: WidgetProfileEditorViewModel = hiltViewModel()
+) {
+ val viewState by viewModel.state.collectAsState(initial = WidgetProfileEditorViewState.Empty)
+ var currentDialog by remember { mutableStateOf(WidgetProfileEditorDialogs.None) }
+ val coroutineScope = rememberCoroutineScope()
+
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ WidgetProfileEditor(
+ viewState = viewState,
+ onNameChange = {
+ viewModel.onNameChanged(it)
+ },
+ onSaveNameClick = {
+ viewModel.saveChanges(viewState)
+ },
+ onAddPackageFilterClick = {
+ currentDialog = WidgetProfileEditorDialogs.AddPackageNamePackageFilter(viewState.packageFilters)
+ },
+ onAddAppSignatureClick = {
+ currentDialog = WidgetProfileEditorDialogs.AddAppSignaturePackageFilter(viewState.packageFilters)
+ },
+ onPackageFilterPreviewClick = {
+ currentDialog = WidgetProfileEditorDialogs.PackageFilterPreview(it)
+ },
+ onPackageFilterInfoClick = { },
+ onDeletePackageFilterClick = {
+ currentDialog = WidgetProfileEditorDialogs.DeletePackageFilter(it)
+ }
+ )
+ }
+ when (val dialog = currentDialog) {
+ WidgetProfileEditorDialogs.None -> Unit
+ is WidgetProfileEditorDialogs.AddAppSignaturePackageFilter -> AddAppSignaturePackageFilterDialog(
+ currentFilters = dialog.currentPackageFilters,
+ closeDialog = {
+ currentDialog = WidgetProfileEditorDialogs.None
+ },
+ appSelected = { appInfo ->
+ coroutineScope.launch {
+ viewModel.addPackageFilter(
+ PackageFilter(
+ filter = appInfo.signatureHashSha256,
+ type = FilterType.SIGNATURE,
+ description = appInfo.name,
+ profileId = viewState.widgetProfile?.id.orEmpty()
+ )
+ )
+ currentDialog = WidgetProfileEditorDialogs.None
+ }
+ }
+ )
+
+ is WidgetProfileEditorDialogs.AddPackageNamePackageFilter -> AddPackageNamePackageFilterDialog(
+ currentFilters = dialog.currentPackageFilters,
+ closeDialog = {
+ currentDialog = WidgetProfileEditorDialogs.None
+ },
+ addFilter = { packageNameFilter ->
+ coroutineScope.launch {
+ viewModel.addPackageFilter(
+ PackageFilter(
+ type = FilterType.PACKAGE_NAME,
+ filter = packageNameFilter,
+ profileId = viewState.widgetProfile?.id.orEmpty()
+ )
+ )
+ currentDialog = WidgetProfileEditorDialogs.None
+ }
+ }
+ )
+
+ is WidgetProfileEditorDialogs.PackageFilterPreview -> PackageFilterPreviewDialog(
+ packageFilter = dialog.packageFilter
+ ) {
+ currentDialog = WidgetProfileEditorDialogs.None
+ }
+
+ is WidgetProfileEditorDialogs.PackageFilterInfo -> Unit
+ is WidgetProfileEditorDialogs.DeletePackageFilter -> DeletePackageFilterDialog(
+ onDismiss = {
+ currentDialog = WidgetProfileEditorDialogs.None
+ },
+ onConfirm = {
+ coroutineScope.launch {
+ viewModel.deleteFilter(dialog.packageFilter)
+ currentDialog = WidgetProfileEditorDialogs.None
+ }
+ }
+ )
+
+ }
+}
+
+private sealed class WidgetProfileEditorDialogs {
+ data object None : WidgetProfileEditorDialogs()
+
+ data class PackageFilterPreview(
+ val packageFilter: PackageFilter
+ ) : WidgetProfileEditorDialogs()
+
+ data class AddPackageNamePackageFilter(
+ val currentPackageFilters: List
+ ) : WidgetProfileEditorDialogs()
+
+ data class AddAppSignaturePackageFilter(
+ val currentPackageFilters: List
+ ) : WidgetProfileEditorDialogs()
+
+ data class PackageFilterInfo(
+ val packageFilter: PackageFilter
+ ) : WidgetProfileEditorDialogs()
+
+ data class DeletePackageFilter(
+ val packageFilter: PackageFilter
+ ) : WidgetProfileEditorDialogs()
+}
+
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+private fun WidgetProfileEditor(
+ viewState: WidgetProfileEditorViewState,
+ onNameChange: (String) -> Unit = {},
+ onSaveNameClick: () -> Unit = {},
+ onAddPackageFilterClick: (WidgetProfile) -> Unit = {},
+ onAddAppSignatureClick: (WidgetProfile) -> Unit = {},
+ onPackageFilterPreviewClick: (PackageFilter) -> Unit = {},
+ onPackageFilterInfoClick: (PackageFilter) -> Unit = {},
+ onDeletePackageFilterClick: (PackageFilter) -> Unit = {}
+) {
+ val widgetProfile = viewState.widgetProfile
+ if (widgetProfile == null) {
+ // Loading
+ Box(modifier = Modifier.defaultMinSize(minHeight = 256.dp), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(modifier = Modifier.size(64.dp))
+ }
+ } else {
+ Box(modifier = Modifier.defaultMinSize(minHeight = 256.dp)) {
+ Column {
+ Surface(modifier = Modifier.wrapContentHeight(), shadowElevation = 2.dp) {
+ Column(
+ modifier = Modifier
+ .wrapContentHeight()
+ .padding(8.dp)
+ ) {
+ WidgetProfileName(
+ widgetName = viewState.widgetName ?: widgetProfile.name,
+ currentName = widgetProfile.name,
+ onNameChange = onNameChange,
+ onSaveNameClick = onSaveNameClick
+ )
+ Row(
+ modifier = Modifier.padding(top = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = { onAddPackageFilterClick(widgetProfile) }
+ ) {
+ Icon(
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ painter = painterResource(id = R.drawable.ic_regex),
+ contentDescription = stringResource(id = R.string.add_package_name)
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(text = stringResource(id = R.string.add_package_name).uppercase(Locale.getDefault()))
+ }
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = { onAddAppSignatureClick(widgetProfile) }
+ ) {
+ Icon(
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ painter = painterResource(id = R.drawable.ic_certificate),
+ contentDescription = stringResource(id = R.string.add_app_signature)
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(text = stringResource(id = R.string.add_app_signature).uppercase(Locale.getDefault()))
+ }
+ }
+ }
+ }
+ LazyColumn(
+ modifier = Modifier.weight(1f),
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(viewState.packageFilters) { packageFilter ->
+ Card {
+ Row(
+ modifier = Modifier.padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val iconRes = when (packageFilter.type) {
+ FilterType.PACKAGE_NAME -> R.drawable.ic_regex
+ FilterType.SIGNATURE -> R.drawable.ic_certificate
+ }
+ Icon(
+ modifier = Modifier.padding(8.dp),
+ painter = painterResource(id = iconRes),
+ contentDescription = null
+ )
+ val text = when (packageFilter.type) {
+ FilterType.PACKAGE_NAME -> packageFilter.filter
+ FilterType.SIGNATURE -> packageFilter.description
+ }
+ Text(modifier = Modifier.weight(1f), text = text)
+ AnimatedVisibility(visible = packageFilter.type == FilterType.SIGNATURE) {
+ Icon(
+ modifier = Modifier
+ .clickable { onPackageFilterInfoClick(packageFilter) }
+ .padding(8.dp),
+ imageVector = Icons.Filled.Info,
+ contentDescription = null
+ )
+ }
+ Icon(
+ modifier = Modifier
+ .clickable { onPackageFilterPreviewClick(packageFilter) }
+ .padding(8.dp),
+ imageVector = Icons.Filled.Preview,
+ contentDescription = null
+ )
+ Icon(
+ modifier = Modifier
+ .clickable { onDeletePackageFilterClick(packageFilter) }
+ .padding(8.dp),
+ imageVector = Icons.Filled.Delete,
+ contentDescription = null
+ )
+ }
+ }
+ }
+ }
+ }
+ FloatingActionButton(
+ onClick = {
+ TODO("Save and Close view")
+ },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 16.dp, bottom = 16.dp)
+ ) {
+ Icon(imageVector = Icons.Outlined.Close, contentDescription = "Pin new widget")
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+fun WidgetProfileName(
+ widgetName: String,
+ currentName: String,
+ onNameChange: (String) -> Unit = {},
+ onSaveNameClick: () -> Unit = {}
+) {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ value = widgetName,
+ onValueChange = onNameChange,
+ label = { Text(text = stringResource(id = R.string.name)) }
+ )
+ AnimatedVisibility(visible = widgetName != currentName) {
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = onSaveNameClick
+ ) {
+ Icon(
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ imageVector = Icons.Outlined.Save,
+ contentDescription = stringResource(id = R.string.save)
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(text = stringResource(id = R.string.save))
+ }
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetProfileEditor_Loading() {
+ DevDrawerTheme {
+ WidgetProfileEditor(
+ viewState = WidgetProfileEditorViewState.Empty
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetProfileEditor_Loaded() {
+ val widgetProfile = WidgetProfile(
+ id = UUID.randomUUID().toString(),
+ name = "Test widget profile"
+ )
+ DevDrawerTheme {
+ WidgetProfileEditor(
+ viewState = WidgetProfileEditorViewState(
+ widgetProfile = widgetProfile,
+ widgetName = widgetProfile.name,
+ packageFilters = listOf(
+ PackageFilter(profileId = widgetProfile.id, filter = "01022402020", type = FilterType.SIGNATURE),
+ PackageFilter(profileId = widgetProfile.id, filter = "com.example2.*")
+ )
+ )
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetProfileEditor_NameChanged() {
+ DevDrawerTheme {
+ WidgetProfileEditor(
+ viewState = WidgetProfileEditorViewState(
+ widgetProfile = WidgetProfile(
+ id = UUID.randomUUID().toString(),
+ name = "Test widget profile"
+ ),
+ widgetName = "Test widget profile 2"
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt
new file mode 100644
index 00000000..c6e7ef39
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt
@@ -0,0 +1,62 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.psdev.devdrawer.database.DevDrawerDatabase
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.navArgs
+import de.psdev.devdrawer.profiles.PackageFilterRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class WidgetProfileEditorViewModel @Inject constructor(
+ private val savedStateHandle: SavedStateHandle,
+ private val database: DevDrawerDatabase,
+ private val packageFilterRepository: PackageFilterRepository
+): ViewModel() {
+
+ private val widgetProfileId: WidgetProfileEditorArgs = savedStateHandle.navArgs()
+ private val widgetNameState: MutableStateFlow = MutableStateFlow(null)
+
+ val state = combine(
+ database.widgetProfileDao().widgetProfileWithIdObservable(widgetProfileId.profileId),
+ database.packageFilterDao().findAllByProfileFlow(widgetProfileId.profileId).distinctUntilChanged(),
+ widgetNameState
+ ) { widgetProfile, packageFilters, name ->
+ WidgetProfileEditorViewState(
+ widgetProfile = widgetProfile,
+ widgetName = name ?: widgetProfile.name,
+ packageFilters = packageFilters
+ )
+ }
+
+ fun onNameChanged(newName: String) {
+ widgetNameState.value = newName
+ }
+
+ fun saveChanges(viewState: WidgetProfileEditorViewState) {
+ val widgetProfile = viewState.widgetProfile ?: return
+ viewModelScope.launch {
+ database.widgetProfileDao().updateWithTimestamp(
+ widgetProfile.copy(
+ name = viewState.widgetName ?: widgetProfile.name
+ )
+ )
+ }
+ }
+
+ suspend fun addPackageFilter(packageFilter: PackageFilter) {
+ packageFilterRepository.save(packageFilter)
+ }
+
+ suspend fun deleteFilter(packageFilter: PackageFilter) {
+ packageFilterRepository.delete(packageFilter)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt
new file mode 100644
index 00000000..09593068
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt
@@ -0,0 +1,16 @@
+package de.psdev.devdrawer.profiles.ui.editor
+
+import androidx.compose.runtime.Immutable
+import de.psdev.devdrawer.database.PackageFilter
+import de.psdev.devdrawer.database.WidgetProfile
+
+@Immutable
+data class WidgetProfileEditorViewState(
+ val widgetProfile: WidgetProfile? = null,
+ val widgetName: String? = null,
+ val packageFilters: List = emptyList()
+) {
+ companion object {
+ val Empty = WidgetProfileEditorViewState()
+ }
+}
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListAdapter.kt
similarity index 93%
rename from app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListAdapter.kt
rename to app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListAdapter.kt
index 8a3d8065..072b174d 100644
--- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListAdapter.kt
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListAdapter.kt
@@ -1,4 +1,4 @@
-package de.psdev.devdrawer.profiles
+package de.psdev.devdrawer.profiles.ui.list
import android.view.ViewGroup
import androidx.recyclerview.selection.SelectionTracker
@@ -6,7 +6,8 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import de.psdev.devdrawer.database.WidgetProfile
import de.psdev.devdrawer.databinding.ListItemWidgetProfileBinding
-import de.psdev.devdrawer.profiles.WidgetProfilesListAdapter.WidgetProfileViewHolder
+import de.psdev.devdrawer.profiles.WidgetActionListener
+import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesListAdapter.WidgetProfileViewHolder
import de.psdev.devdrawer.utils.consume
import de.psdev.devdrawer.utils.layoutInflater
diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt
new file mode 100644
index 00000000..960bf8d9
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt
@@ -0,0 +1,210 @@
+package de.psdev.devdrawer.profiles.ui.list
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Add
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.database.WidgetProfile
+import de.psdev.devdrawer.destinations.WidgetProfileEditorDestination
+import de.psdev.devdrawer.profiles.DeleteDialogState
+import de.psdev.devdrawer.profiles.WidgetInUseErrorAlertDialog
+import de.psdev.devdrawer.profiles.WidgetProfileList
+import de.psdev.devdrawer.profiles.WidgetProfilesViewModel
+import de.psdev.devdrawer.ui.loading.LoadingView
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import kotlinx.coroutines.launch
+import java.util.UUID
+
+@Destination
+@Composable
+fun WidgetProfilesScreen(
+ destinationsNavigator: DestinationsNavigator,
+ viewModel: WidgetProfilesViewModel = hiltViewModel(),
+) {
+ WidgetProfilesScreen(
+ viewModel = viewModel,
+ editProfile = {
+ destinationsNavigator.navigate(WidgetProfileEditorDestination(it.id))
+ }
+ )
+}
+
+
+@Composable
+fun WidgetProfilesScreen(
+ viewModel: WidgetProfilesViewModel = hiltViewModel(),
+ editProfile: (WidgetProfile) -> Unit = {}
+) {
+ val coroutineScope = rememberCoroutineScope()
+ var deleteDialogShown by remember { mutableStateOf(DeleteDialogState.Hidden) }
+ val viewState by viewModel.viewState.collectAsState()
+ WidgetProfileListScreen(
+ viewState = viewState,
+ onWidgetProfileClick = editProfile,
+ onWidgetProfileLongClick = { widgetProfile ->
+ coroutineScope.launch {
+ val widgets = viewModel.devDrawerDatabase.widgetDao().findAllByProfileId(widgetProfile.id)
+ deleteDialogShown = if (widgets.isNotEmpty()) {
+ DeleteDialogState.InUseError(
+ widgetProfile = widgetProfile,
+ widgets = widgets
+ )
+ } else {
+ DeleteDialogState.Showing(widgetProfile)
+ }
+ }
+ },
+ onCreateWidgetProfileClick = {
+ coroutineScope.launch {
+ val widgetProfile = viewModel.createNewProfile()
+ editProfile(widgetProfile)
+ }
+ }
+ )
+ when (val state = deleteDialogShown) {
+ DeleteDialogState.Hidden -> Unit
+ is DeleteDialogState.Showing -> AlertDialog(
+ onDismissRequest = { },
+ title = {
+ Text(text = "Confirm")
+ },
+ text = {
+ Text(text = "Do you really want to delete the profile '${state.widgetProfile.name}'?")
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ coroutineScope.launch {
+ viewModel.deleteProfile(state.widgetProfile)
+ deleteDialogShown = DeleteDialogState.Hidden
+ }
+ }) {
+ Text("Delete")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ deleteDialogShown = DeleteDialogState.Hidden
+ }) {
+ Text("Cancel")
+ }
+ }
+ )
+
+ is DeleteDialogState.InUseError -> {
+ WidgetInUseErrorAlertDialog(state, onDismiss = {
+ deleteDialogShown = DeleteDialogState.Hidden
+ })
+ }
+ }
+}
+
+@Composable
+fun WidgetProfileListScreen(
+ viewState: WidgetProfilesViewModel.ViewState,
+ onWidgetProfileClick: (WidgetProfile) -> Unit = {},
+ onWidgetProfileLongClick: (WidgetProfile) -> Unit = {},
+ onCreateWidgetProfileClick: () -> Unit = {}
+) {
+ when (viewState) {
+ WidgetProfilesViewModel.ViewState.Loading -> LoadingView()
+ is WidgetProfilesViewModel.ViewState.Loaded -> {
+ val profiles = viewState.data
+ if (profiles.isEmpty()) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ color = MaterialTheme.colorScheme.onBackground,
+ text = stringResource(id = R.string.no_profiles)
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ Button(onClick = onCreateWidgetProfileClick) {
+ Icon(
+ imageVector = Icons.Outlined.Add,
+ contentDescription = stringResource(id = R.string.widget_profile_list_create_new)
+ )
+ Text(text = stringResource(id = R.string.widget_profile_list_create_new))
+ }
+ }
+ } else {
+ Box(modifier = Modifier.fillMaxSize()) {
+ WidgetProfileList(
+ widgetProfiles = profiles,
+ onWidgetProfileClick = onWidgetProfileClick,
+ onWidgetProfileLongClick = onWidgetProfileLongClick
+ )
+ FloatingActionButton(
+ onClick = onCreateWidgetProfileClick,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 16.dp, bottom = 16.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Add,
+ contentDescription = stringResource(id = R.string.widget_profile_list_create_new)
+ )
+ }
+ }
+ }
+ }
+ }
+
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetProfileListScreen_Empty() {
+ DevDrawerTheme {
+ WidgetProfileListScreen(
+ viewState = WidgetProfilesViewModel.ViewState.Loaded(emptyList())
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetProfileListScreen_Profiles() {
+ DevDrawerTheme {
+ WidgetProfileListScreen(
+ viewState = WidgetProfilesViewModel.ViewState.Loaded(
+ listOf(
+ WidgetProfile(UUID.randomUUID().toString(), "Profile 1"),
+ WidgetProfile(UUID.randomUUID().toString(), "Profile 2"),
+ )
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt b/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt
new file mode 100644
index 00000000..be09343a
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt
@@ -0,0 +1,38 @@
+package de.psdev.devdrawer.receivers
+
+import android.appwidget.AppWidgetManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.work.Data
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import de.psdev.devdrawer.widgets.SaveWidgetWorker
+import mu.KLogging
+
+class PinWidgetSuccessReceiver : BroadcastReceiver() {
+
+ companion object : KLogging() {
+ fun intent(context: Context): Intent = Intent(context, PinWidgetSuccessReceiver::class.java)
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ logger.warn { "onReceive[context=$context, intent=$intent]" }
+ val widgetId = intent.getIntExtra(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID
+ )
+ if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
+ val inputData = Data.Builder().putInt(SaveWidgetWorker.ARG_WIDGET_ID, widgetId).build()
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(inputData)
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ "SAVE_WIDGET_$widgetId",
+ ExistingWorkPolicy.REPLACE,
+ request
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/review/ReviewManager.kt b/app/src/main/java/de/psdev/devdrawer/review/ReviewManager.kt
index 234f6789..b73b917f 100644
--- a/app/src/main/java/de/psdev/devdrawer/review/ReviewManager.kt
+++ b/app/src/main/java/de/psdev/devdrawer/review/ReviewManager.kt
@@ -4,6 +4,8 @@ import android.app.Activity
import android.app.Application
import android.content.SharedPreferences
import androidx.core.content.edit
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailabilityLight
import com.google.android.play.core.ktx.launchReview
import com.google.android.play.core.ktx.requestReview
import com.google.android.play.core.review.ReviewManagerFactory
@@ -33,18 +35,26 @@ class ReviewManager @Inject constructor(
set(value) = sharedPreferences.edit { putLong(PREF_KEY_LAST_LAUNCH_MILLIS, value) }
suspend fun triggerReview(activity: Activity) {
- if (!remoteConfigService.getBoolean(KEY_ENABLED)) return
- if (shouldLaunchReview()) {
- logger.info { "Requesting review" }
- val reviewInfo = reviewManager.requestReview()
- reviewManager.launchReview(activity, reviewInfo)
- lastReviewLaunchMillis = System.currentTimeMillis()
- } else {
- logger.info { "Conditions not met, skipping review" }
+ try {
+ if (!remoteConfigService.getBoolean(KEY_ENABLED)) return
+ if (shouldLaunchReview()) {
+ logger.info { "Requesting review" }
+ val reviewInfo = reviewManager.requestReview()
+ reviewManager.launchReview(activity, reviewInfo)
+ lastReviewLaunchMillis = System.currentTimeMillis()
+ } else {
+ logger.info { "Conditions not met, skipping review" }
+ }
+ } catch (e: RuntimeException) {
+ logger.warn { "Error triggering review request: ${e.message}" }
}
}
private suspend fun shouldLaunchReview(): Boolean {
+ val servicesAvailable = GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(application)
+ if (servicesAvailable != ConnectionResult.SUCCESS) {
+ return false
+ }
val minWidgetsConfig = remoteConfigService.getInteger(KEY_MIN_WIDGETS)
val currentWidgetCount = devDrawerDatabase.widgetDao().findAll().count()
if (currentWidgetCount < minWidgetsConfig) {
diff --git a/app/src/main/java/de/psdev/devdrawer/settings/ListPreference.kt b/app/src/main/java/de/psdev/devdrawer/settings/ListPreference.kt
new file mode 100644
index 00000000..62c91245
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/settings/ListPreference.kt
@@ -0,0 +1,144 @@
+package de.psdev.devdrawer.settings
+
+import android.content.res.Configuration
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.appwidget.SortOrder
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+fun ListPreference(
+ label: String,
+ values: Map,
+ currentValue: T,
+ dialogTitle: String = "Select option",
+ onClick: (T) -> Unit = {}
+) {
+ var selectionDialog by remember {
+ mutableStateOf(false)
+ }
+ require(currentValue in values.keys) { "currentValue needs to be a key in values" }
+ Column(
+ modifier = Modifier
+ .defaultMinSize(minHeight = 64.dp)
+ .fillMaxWidth()
+ .padding(8.dp)
+ .clickable { selectionDialog = true }
+ ) {
+ Text(
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ text = label
+ )
+ Text(
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primaryContainer,
+ text = requireNotNull(values[currentValue])
+ )
+ if (selectionDialog) {
+ var selection by remember { mutableStateOf(currentValue) }
+ AlertDialog(
+ onDismissRequest = { selectionDialog = false },
+ title = { Text(text = dialogTitle) },
+ text = {
+ LazyColumn(
+ modifier = Modifier
+ .wrapContentHeight()
+ .fillMaxWidth()
+ ) {
+ val list: List = values.keys.toList()
+ items(list) { item ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = (item == selection),
+ onClick = { selection = item }
+ )
+ .padding(8.dp)
+
+ ) {
+ RadioButton(
+ selected = selection == item,
+ onClick = { selection = item }
+ )
+ Text(
+ text = values[item].orEmpty(),
+ style = MaterialTheme.typography.bodyMedium.merge(),
+ modifier = Modifier.padding(start = 16.dp)
+ )
+ }
+ }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { selectionDialog = false }) {
+ Text(text = stringResource(id = R.string.cancel))
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ selectionDialog = false
+ onClick(selection)
+ }) {
+ Text(text = stringResource(id = R.string.ok))
+ }
+ }
+ )
+ }
+ }
+}
+
+@Preview(name = "Light Mode (Enabled)", showSystemUi = true)
+@Preview(
+ name = "Dark Mode (Enabled)",
+ showSystemUi = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES
+)
+@Composable
+fun Preview_SelectionPreference_Enabled() {
+ DevDrawerTheme {
+ Box(modifier = Modifier.fillMaxSize()) {
+ ListPreference(
+ label = "Setting 1",
+ values = mapOf(
+ SortOrder.LAST_UPDATED to "Last updated",
+ SortOrder.FIRST_INSTALLED to "First installed"
+ ),
+ currentValue = SortOrder.FIRST_INSTALLED
+ )
+ }
+ }
+}
+
+@Preview(name = "Light Mode (Disabled)", showSystemUi = true)
+@Preview(
+ name = "Dark Mode (Disabled)",
+ showSystemUi = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES
+)
+@Composable
+fun Preview_SelectionPreference_Disabled() {
+ DevDrawerTheme {
+ Box(modifier = Modifier.fillMaxSize()) {
+ ListPreference(
+ label = "Setting 1",
+ values = mapOf(
+ SortOrder.LAST_UPDATED to "Last updated",
+ SortOrder.FIRST_INSTALLED to "First installed"
+ ),
+ currentValue = SortOrder.FIRST_INSTALLED
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt b/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt
index f03d2127..9a396c9e 100644
--- a/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt
+++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt
@@ -1,72 +1,32 @@
package de.psdev.devdrawer.settings
-import android.appwidget.AppWidgetManager
-import android.content.ComponentName
import android.os.Bundle
-import androidx.annotation.StringRes
-import androidx.core.content.edit
-import androidx.lifecycle.lifecycleScope
-import androidx.preference.*
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.ui.platform.ComposeView
import dagger.hilt.android.AndroidEntryPoint
-import de.psdev.devdrawer.R
-import de.psdev.devdrawer.analytics.TrackingService
-import de.psdev.devdrawer.appwidget.DDWidgetProvider
+import de.psdev.devdrawer.BaseFragment
import de.psdev.devdrawer.config.RemoteConfigService
-import java.util.*
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
import javax.inject.Inject
@AndroidEntryPoint
-class SettingsFragment: PreferenceFragmentCompat() {
+class SettingsFragment: BaseFragment() {
@Inject
lateinit var remoteConfigService: RemoteConfigService
- override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
- addPreferencesFromResource(R.xml.preferences)
-
- findPreference(R.string.pref_sort_order).apply {
- summary = sortOrderLabelFromValue(
- sharedPreferences.getString(
- getString(R.string.pref_sort_order),
- getString(R.string.pref_sort_order_default)
- ).orEmpty()
- )
- setOnPreferenceChangeListener { preference, newValue ->
- sharedPreferences.edit {
- putString(preference.key, newValue.toString())
- }
-
- preference.summary = sortOrderLabelFromValue(newValue.toString())
-
- val appWidgetManager = AppWidgetManager.getInstance(context)
- val appWidgetIds =
- appWidgetManager.getAppWidgetIds(ComponentName(context, DDWidgetProvider::class.java))
- appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.listView)
-
- return@setOnPreferenceChangeListener true
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View = ComposeView(requireContext()).apply {
+ setContent {
+ DevDrawerTheme {
+ SettingsScreen()
}
}
- val analyticsCategory = requireNotNull(findPreference("feature_analytics"))
- val analyticsPreference = findPreference(R.string.pref_feature_analytics_opted_in)
- lifecycleScope.launchWhenResumed {
- val analyticsEnabled = remoteConfigService.getBoolean(TrackingService.CONFIG_KEY_ENABLED)
- analyticsCategory.isVisible = analyticsEnabled
- analyticsPreference.isVisible = analyticsEnabled
- }
- }
-
- // ==========================================================================================================================
- // Private API
- // ==========================================================================================================================
-
- private inline fun findPreference(@StringRes keyRes: Int): T =
- requireNotNull(findPreference(getString(keyRes)))
-
- private fun sortOrderLabelFromValue(value: String): String {
- val resources = resources
- val values = resources.getStringArray(R.array.sort_order_values)
- val names = resources.getStringArray(R.array.sort_order_labels)
- return names[values.indexOfFirst { it == value }]
}
}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt b/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt
new file mode 100644
index 00000000..f66e81ef
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt
@@ -0,0 +1,100 @@
+package de.psdev.devdrawer.settings
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Divider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.ramcosta.composedestinations.annotation.Destination
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.appwidget.SortOrder
+import de.psdev.devdrawer.ui.loading.LoadingView
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+@Destination
+fun SettingsScreen(
+ viewModel: SettingsViewModel = hiltViewModel()
+) {
+ val viewState by viewModel.viewState.collectAsState()
+
+ SettingsScreen(
+ viewState = viewState,
+ onActivityChooserChanged = {
+ viewModel.onActivityChooserChanged(it)
+ },
+ onSortOrderChanged = {
+ viewModel.onSortOrderChanged(it)
+ },
+ onAnalyticsOptInChanged = {
+ viewModel.onAnalyticsOptInChanged(it)
+ }
+ )
+}
+
+@Composable
+fun SettingsScreen(
+ viewState: SettingsViewModel.ViewState,
+ onActivityChooserChanged: (Boolean) -> Unit = {},
+ onSortOrderChanged: (SortOrder) -> Unit = {},
+ onAnalyticsOptInChanged: (Boolean) -> Unit = {},
+) {
+ when (viewState) {
+ SettingsViewModel.ViewState.Loading -> LoadingView(modifier = Modifier.fillMaxSize())
+ is SettingsViewModel.ViewState.Loaded -> {
+ val settings = viewState.settings
+ Column {
+ SwitchPreference(
+ text = stringResource(id = R.string.pref_show_activity_choice_title),
+ enabled = settings.activityChooserEnabled
+ ) {
+ onActivityChooserChanged(it)
+ }
+ Divider()
+ val labels = stringArrayResource(id = R.array.sort_order_labels)
+ ListPreference(
+ label = stringResource(id = R.string.pref_sort_order_title),
+ values = SortOrder.values().mapIndexed { index, sortOrder ->
+ sortOrder to labels[index]
+ }.toMap(),
+ currentValue = settings.defaultSortOrder
+ ) {
+ onSortOrderChanged(it)
+ }
+ Divider()
+ AnimatedVisibility(visible = viewState.analyticsVisible) {
+ SwitchPreference(
+ text = stringResource(id = R.string.pref_feature_analytics_opted_in_title),
+ enabled = settings.analyticsOptIn
+ ) {
+ onAnalyticsOptInChanged(it)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun Preview_SettingsScreen() {
+ DevDrawerTheme {
+ SettingsScreen(
+ viewState = SettingsViewModel.ViewState.Loaded(
+ settings = SettingsViewModel.Settings(
+ activityChooserEnabled = true,
+ defaultSortOrder = SortOrder.LAST_UPDATED,
+ analyticsOptIn = true
+ ),
+ analyticsVisible = true
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SettingsViewModel.kt b/app/src/main/java/de/psdev/devdrawer/settings/SettingsViewModel.kt
new file mode 100644
index 00000000..2fca053c
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsViewModel.kt
@@ -0,0 +1,123 @@
+package de.psdev.devdrawer.settings
+
+import android.app.Application
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.analytics.TrackingService
+import de.psdev.devdrawer.appwidget.SortOrder
+import de.psdev.devdrawer.config.RemoteConfigService
+import de.psdev.devdrawer.receivers.UpdateReceiver
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
+import mu.KLogging
+import javax.inject.Inject
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val application: Application,
+ private val remoteConfigService: RemoteConfigService,
+ private val sharedPreferences: SharedPreferences
+): ViewModel() {
+ companion object: KLogging()
+
+ val persistedSettings = callbackFlow {
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, _ ->
+ trySendBlocking(sharedPreferences.loadSettings(application))
+ }
+ send(sharedPreferences.loadSettings(application))
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ awaitClose {
+ sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+ }
+
+ val viewState: MutableStateFlow = MutableStateFlow(ViewState.Loading)
+
+ init {
+ viewModelScope.launch {
+ // TODO Convert to Flow
+ val analyticsEnabled = remoteConfigService.getBoolean(TrackingService.CONFIG_KEY_ENABLED)
+ settingsFlow().collect { settings ->
+ viewState.value = ViewState.Loaded(
+ analyticsVisible = analyticsEnabled,
+ settings = settings
+ )
+ }
+ }
+ }
+
+ fun onActivityChooserChanged(enabled: Boolean) {
+ sharedPreferences.edit {
+ putBoolean(application.getString(R.string.pref_show_activity_choice), enabled)
+ }
+ onSettingsUpdated()
+ }
+
+ fun onSortOrderChanged(sortOrder: SortOrder) {
+ sharedPreferences.edit {
+ putString(application.getString(R.string.pref_sort_order), sortOrder.name)
+ }
+ onSettingsUpdated()
+ }
+
+ fun onAnalyticsOptInChanged(enabled: Boolean) {
+ sharedPreferences.edit {
+ putBoolean(application.getString(R.string.pref_feature_analytics_opted_in), enabled)
+ }
+ onSettingsUpdated()
+ }
+
+ // ==========================================================================================================================
+ // Private API
+ // ==========================================================================================================================
+
+ private fun settingsFlow(): Flow = callbackFlow {
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences: SharedPreferences, key: String? ->
+ logger.warn { "Setting updated: $key" }
+ trySendBlocking(sharedPreferences.loadSettings(application))
+ }
+ send(sharedPreferences.loadSettings(application))
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ awaitClose {
+ sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+ }
+
+ private fun onSettingsUpdated() {
+ UpdateReceiver.send(application)
+ }
+
+ sealed class ViewState {
+ data object Loading : ViewState()
+ data class Loaded(
+ val analyticsVisible: Boolean,
+ val settings: Settings
+ ): ViewState()
+ }
+
+ data class Settings(
+ val activityChooserEnabled: Boolean,
+ val defaultSortOrder: SortOrder,
+ val analyticsOptIn: Boolean
+ )
+
+ private fun SharedPreferences.loadSettings(application: Application): Settings = Settings(
+ activityChooserEnabled = getBoolean(
+ application.getString(R.string.pref_show_activity_choice),
+ application.resources.getBoolean(R.bool.pref_show_activity_choice_default)
+ ),
+ defaultSortOrder = SortOrder.valueOf(
+ getString(application.resources.getString(R.string.pref_sort_order), null) ?: application.getString(R.string.pref_sort_order_default)
+ ),
+ analyticsOptIn = getBoolean(application.getString(R.string.pref_feature_analytics_opted_in), false)
+ )
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SwitchPreference.kt b/app/src/main/java/de/psdev/devdrawer/settings/SwitchPreference.kt
new file mode 100644
index 00000000..0eb6175f
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/settings/SwitchPreference.kt
@@ -0,0 +1,50 @@
+package de.psdev.devdrawer.settings
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+fun SwitchPreference(
+ text: String,
+ enabled: Boolean,
+ onChange: (Boolean) -> Unit = {}
+) {
+ Row(
+ modifier = Modifier
+ .defaultMinSize(minHeight = 64.dp)
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.primary, text = text)
+ Switch(checked = enabled, onCheckedChange = onChange)
+ }
+}
+
+@Preview(name = "Light Mode (Enabled)")
+@Preview(name = "Dark Mode (Enabled)", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_SwitchPreference_Enabled() {
+ DevDrawerTheme {
+ SwitchPreference(text = "Test", enabled = true)
+ }
+}
+
+@Preview(name = "Light Mode (Disabled)")
+@Preview(name = "Dark Mode (Disabled)", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_SwitchPreference_Disabled() {
+ DevDrawerTheme {
+ SwitchPreference(text = "Test", enabled = false)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/ui/OverflowMenu.kt b/app/src/main/java/de/psdev/devdrawer/ui/OverflowMenu.kt
new file mode 100644
index 00000000..9b65ce1f
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/ui/OverflowMenu.kt
@@ -0,0 +1,40 @@
+package de.psdev.devdrawer.ui
+
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.runtime.*
+import androidx.compose.ui.res.stringResource
+import de.psdev.devdrawer.R
+
+interface OverflowMenuScope {
+ fun closeMenu()
+}
+
+@Composable
+fun OverflowMenu(content: @Composable OverflowMenuScope.() -> Unit) {
+ var showMenu by remember { mutableStateOf(false) }
+ val scope = remember {
+ object : OverflowMenuScope {
+ override fun closeMenu() {
+ showMenu = false
+ }
+ }
+ }
+ IconButton(onClick = {
+ showMenu = !showMenu
+ }) {
+ Icon(
+ imageVector = Icons.Outlined.MoreVert,
+ contentDescription = stringResource(R.string.more),
+ )
+ }
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ scope.content()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt b/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt
new file mode 100644
index 00000000..a786bd03
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt
@@ -0,0 +1,149 @@
+package de.psdev.devdrawer.ui.autocomplete
+
+import android.content.res.Configuration
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material3.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInParent
+import androidx.compose.ui.layout.positionInWindow
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Popup
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import kotlin.math.roundToInt
+
+@Composable
+fun AutoCompleteTextView(
+ modifier: Modifier = Modifier,
+ options: List,
+ label: @Composable (() -> Unit)? = null,
+ onTextChanged: (String) -> Unit = {}
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val textFieldFocused by interactionSource.collectIsFocusedAsState()
+ var text by remember { mutableStateOf("") }
+ var textPosition by remember {
+ mutableStateOf(TextViewPositionData())
+ }
+ SelectionContainer {
+ OutlinedTextField(
+ modifier = modifier
+ .fillMaxWidth()
+ .onGloballyPositioned {
+ textPosition = TextViewPositionData(
+ positionInWindow = it.positionInWindow(),
+ positionInParent = it.positionInParent(),
+ size = it.size
+ )
+ },
+ value = text,
+ trailingIcon = {
+ if (text.isNotBlank()) {
+ Icon(
+ // TODO Fix size and clipping
+ modifier = Modifier
+ .clip(MaterialTheme.shapes.small.copy(CornerSize(percent = 50)))
+ .clickable {
+ text = ""
+ onTextChanged(text)
+ },
+ imageVector = Icons.Filled.Clear,
+ contentDescription = stringResource(id = R.string.clear)
+ )
+ }
+ },
+ interactionSource = interactionSource,
+ onValueChange = {
+ text = it
+ onTextChanged(text)
+ },
+ label = label,
+ keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Ascii)
+ )
+ }
+ if (textFieldFocused) {
+ val textFieldStart = textPosition.positionInParent.x.roundToInt()
+ val textFieldBottom = (textPosition.positionInParent.y + textPosition.size.height).roundToInt()
+ val popupOffset = IntOffset(textFieldStart, textFieldBottom)
+ val popupWidth = with(LocalDensity.current) { textPosition.size.width.toDp() }
+ val bottom = LocalView.current.height
+ val popupHeightMax = with(LocalDensity.current) { (bottom - textFieldBottom).toDp() }
+ Popup(
+ offset = popupOffset,
+ ) {
+ Surface(
+ modifier = Modifier
+ .requiredWidth(popupWidth)
+ .heightIn(max = popupHeightMax),
+ shadowElevation = 2.dp
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ ) {
+ items(options.filter { option -> option.contains(text) && option != text }) { packageName ->
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ text = packageName
+ onTextChanged(text)
+ }
+ .padding(16.dp),
+ text = packageName
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+data class TextViewPositionData(
+ val positionInWindow: Offset = Offset.Unspecified,
+ val positionInParent: Offset = Offset.Unspecified,
+ val size: IntSize = IntSize.Zero
+)
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_AutoCompleteTextView() {
+ DevDrawerTheme {
+ Surface {
+ Column {
+ AutoCompleteTextView(
+ options = listOf(
+ "com.example.app1",
+ "com.example.app2",
+ "com.example.app3",
+ "com.example.app4",
+ ),
+ label = { Text(text = stringResource(id = R.string.packagefilter)) },
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/ui/dialog/DefaultDialog.kt b/app/src/main/java/de/psdev/devdrawer/ui/dialog/DefaultDialog.kt
new file mode 100644
index 00000000..d1c6238b
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/ui/dialog/DefaultDialog.kt
@@ -0,0 +1,63 @@
+package de.psdev.devdrawer.ui.dialog
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.Divider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+
+@Composable
+fun DefaultDialog(
+ onDismissRequest: () -> Unit,
+ titleContent: @Composable ColumnScope.() -> Unit,
+ bottomContent: @Composable ColumnScope.() -> Unit,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = DialogProperties(dismissOnClickOutside = false),
+ ) {
+ Surface(
+ modifier = Modifier
+ .wrapContentHeight()
+ .padding(16.dp),
+ color = MaterialTheme.colorScheme.surface,
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Column(
+ modifier = Modifier
+ .wrapContentHeight()
+ .padding(8.dp)
+ ) {
+ Column {
+ titleContent()
+ }
+ Spacer(modifier = Modifier.size(4.dp))
+ Divider()
+ Spacer(modifier = Modifier.size(4.dp))
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f, false)
+ ) {
+ content()
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ Column(modifier = Modifier.fillMaxWidth()) {
+ bottomContent()
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/ui/loading/LoadingView.kt b/app/src/main/java/de/psdev/devdrawer/ui/loading/LoadingView.kt
new file mode 100644
index 00000000..430d3e3f
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/ui/loading/LoadingView.kt
@@ -0,0 +1,43 @@
+package de.psdev.devdrawer.ui.loading
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+fun LoadingView(
+ modifier: Modifier = Modifier,
+ showText: Boolean = true
+) {
+ Column(
+ modifier = modifier.wrapContentSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(64.dp))
+ if (showText) {
+ Spacer(modifier = Modifier.size(16.dp))
+ Text(text = stringResource(id = R.string.loading))
+ }
+ }
+}
+
+@Preview
+@Composable
+fun Preview_LoadingView() {
+ DevDrawerTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ LoadingView()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/ui/theme/Color.kt b/app/src/main/java/de/psdev/devdrawer/ui/theme/Color.kt
new file mode 100644
index 00000000..99e85a57
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package de.psdev.devdrawer.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val LightGreen500 = Color(0xFF8BC34A)
+val LightGreen700 = Color(0xFF689F38)
+val LightGreen200 = Color(0xFFC5E1A5)
+
+val Orange500 = Color(0xFFff9800)
+val Orange700 = Color(0xFFf57c00)
+val Orange200 = Color(0xFFffcc80)
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/ui/theme/Shape.kt b/app/src/main/java/de/psdev/devdrawer/ui/theme/Shape.kt
new file mode 100644
index 00000000..8d22d415
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/Shape.kt
@@ -0,0 +1,11 @@
+package de.psdev.devdrawer.ui.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.unit.dp
+
+val Shapes = Shapes(
+ small = RoundedCornerShape(4.dp),
+ medium = RoundedCornerShape(4.dp),
+ large = RoundedCornerShape(0.dp)
+)
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/ui/theme/Theme.kt b/app/src/main/java/de/psdev/devdrawer/ui/theme/Theme.kt
new file mode 100644
index 00000000..5bad6da9
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/Theme.kt
@@ -0,0 +1,47 @@
+package de.psdev.devdrawer.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+private val DarkColorPalette = darkColorScheme(
+ primary = LightGreen500,
+ primaryContainer = LightGreen200,
+ secondary = Orange700,
+ background = Color(0xFF575757),
+ surface = Color(0xFF575757),
+ onPrimary = Color.White,
+ onSecondary = Color.Black,
+ onBackground = Color.White,
+ onSurface = Color.White,
+ onError = Color.Black
+)
+
+private val LightColorPalette = lightColorScheme(
+ primary = LightGreen500,
+ primaryContainer = LightGreen200,
+ secondary = Orange700,
+ surface = Color(0xFFDDDDDD)
+)
+
+@Composable
+fun DevDrawerTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val colors = if (darkTheme) {
+ DarkColorPalette
+ } else {
+ LightColorPalette
+ }
+
+ MaterialTheme(
+ colorScheme = colors,
+// typography = Typography,
+// shapes = Shapes,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/ui/theme/Type.kt b/app/src/main/java/de/psdev/devdrawer/ui/theme/Type.kt
new file mode 100644
index 00000000..c772ed0f
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/Type.kt
@@ -0,0 +1,28 @@
+package de.psdev.devdrawer.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp
+ )
+ /* Other default text styles to override
+ button = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W500,
+ fontSize = 14.sp
+ ),
+ caption = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/utils/Constants.kt b/app/src/main/java/de/psdev/devdrawer/utils/Constants.kt
index 592097a5..db8ec998 100644
--- a/app/src/main/java/de/psdev/devdrawer/utils/Constants.kt
+++ b/app/src/main/java/de/psdev/devdrawer/utils/Constants.kt
@@ -1,8 +1,6 @@
package de.psdev.devdrawer.utils
object Constants {
- const val ACTION_WIDGET_PINNED = "de.psdev.devdrawer.WIDGET_PINNED"
-
const val LAUNCH_APP = 1
const val LAUNCH_APP_DETAILS = 2
const val LAUNCH_UNINSTALL = 3
diff --git a/app/src/main/java/de/psdev/devdrawer/utils/DefaultPreviews.kt b/app/src/main/java/de/psdev/devdrawer/utils/DefaultPreviews.kt
new file mode 100644
index 00000000..7957693b
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/utils/DefaultPreviews.kt
@@ -0,0 +1,10 @@
+package de.psdev.devdrawer.utils
+
+import android.content.res.Configuration
+import androidx.compose.ui.tooling.preview.Preview
+
+@Preview(name = "Light")
+@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(name = "Light (German)", locale = "de")
+@Preview(name = "Dark (German)", uiMode = Configuration.UI_MODE_NIGHT_YES, locale = "de")
+annotation class DefaultPreviews()
diff --git a/app/src/main/java/de/psdev/devdrawer/utils/Firebase.kt b/app/src/main/java/de/psdev/devdrawer/utils/Firebase.kt
index da6266b9..95969cc2 100644
--- a/app/src/main/java/de/psdev/devdrawer/utils/Firebase.kt
+++ b/app/src/main/java/de/psdev/devdrawer/utils/Firebase.kt
@@ -1,8 +1,8 @@
package de.psdev.devdrawer.utils
import com.google.firebase.perf.FirebasePerformance
-import com.google.firebase.perf.ktx.trace
import com.google.firebase.perf.metrics.Trace
+import com.google.firebase.perf.trace
inline fun FirebasePerformance.trace(traceName: String, block: Trace.() -> T): T {
return newTrace(traceName).trace(block)
diff --git a/app/src/main/java/de/psdev/devdrawer/utils/PackageManagerKt.kt b/app/src/main/java/de/psdev/devdrawer/utils/PackageManagerKt.kt
index 6669454c..97cb6399 100644
--- a/app/src/main/java/de/psdev/devdrawer/utils/PackageManagerKt.kt
+++ b/app/src/main/java/de/psdev/devdrawer/utils/PackageManagerKt.kt
@@ -15,15 +15,15 @@ fun PackageManager.getExistingPackages(): List {
val appSet = mutableSetOf()
activities.forEach { resolveInfo ->
- var appName = resolveInfo.activityInfo.applicationInfo.packageName
- appSet.add(appName)
- while (appName.isNotEmpty()) {
- val lastIndex = appName.lastIndexOf(".")
+ var packageName = resolveInfo.activityInfo.applicationInfo.packageName
+ appSet.add(packageName)
+ while (packageName.isNotEmpty()) {
+ val lastIndex = packageName.lastIndexOf(".")
if (lastIndex > 0) {
- appName = appName.substring(0, lastIndex)
- appSet.add(appName + ".*")
+ packageName = packageName.substring(0, lastIndex)
+ appSet.add("$packageName.*")
} else {
- appName = ""
+ packageName = ""
}
}
}
diff --git a/app/src/main/java/de/psdev/devdrawer/utils/ViewModels.kt b/app/src/main/java/de/psdev/devdrawer/utils/ViewModels.kt
index 244aacd4..580aae99 100644
--- a/app/src/main/java/de/psdev/devdrawer/utils/ViewModels.kt
+++ b/app/src/main/java/de/psdev/devdrawer/utils/ViewModels.kt
@@ -5,7 +5,5 @@ import androidx.lifecycle.ViewModelProvider.Factory
fun simpleFactory(block: () -> T): Factory = object : Factory {
@Suppress("UNCHECKED_CAST")
- override fun create(
- modelClass: Class
- ): T = block() as T
+ override fun create(modelClass: Class): T = block() as T
}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/CleanupWidgetsWorker.kt b/app/src/main/java/de/psdev/devdrawer/widgets/CleanupWidgetsWorker.kt
index 7e6f5996..bb919b41 100644
--- a/app/src/main/java/de/psdev/devdrawer/widgets/CleanupWidgetsWorker.kt
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/CleanupWidgetsWorker.kt
@@ -28,9 +28,17 @@ class CleanupWidgetsWorker @AssistedInject constructor(
fun enableWorker(application: Application) {
val workManager = WorkManager.getInstance(application)
- val request =
+
+ workManager.enqueueUniqueWork(
+ TAG,
+ ExistingWorkPolicy.APPEND_OR_REPLACE,
+ OneTimeWorkRequestBuilder().build()
+ )
+ workManager.enqueueUniquePeriodicWork(
+ TAG,
+ ExistingPeriodicWorkPolicy.REPLACE,
PeriodicWorkRequestBuilder(30, TimeUnit.MINUTES).build()
- workManager.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request)
+ )
}
}
@@ -49,14 +57,14 @@ class CleanupWidgetsWorker @AssistedInject constructor(
)
).toList()
- val deletedWidgets = databaseWidgetIds - appWidgetIds
+ val deletedWidgets = databaseWidgetIds - appWidgetIds.toSet()
if (deletedWidgets.isNotEmpty()) {
logger.warn { "Deleting orphaned widgets from local database: ${widgets.filter { it.id in deletedWidgets }}" }
widgetDao.deleteByIds(deletedWidgets)
}
- val unconfiguredWidgets = appWidgetIds - databaseWidgetIds
+ val unconfiguredWidgets = appWidgetIds - databaseWidgetIds.toSet()
if (unconfiguredWidgets.isNotEmpty()) {
val widgetProfileDao = devDrawerDatabase.widgetProfileDao()
val defaultWidgetProfile =
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragment.kt
deleted file mode 100644
index 74561804..00000000
--- a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragment.kt
+++ /dev/null
@@ -1,201 +0,0 @@
-package de.psdev.devdrawer.widgets
-
-import android.app.Activity
-import android.appwidget.AppWidgetManager
-import android.content.Intent
-import android.graphics.Color
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.view.isVisible
-import androidx.fragment.app.viewModels
-import androidx.lifecycle.lifecycleScope
-import androidx.navigation.fragment.findNavController
-import androidx.navigation.fragment.navArgs
-import androidx.recyclerview.selection.SelectionPredicates
-import androidx.recyclerview.selection.SelectionTracker
-import androidx.recyclerview.selection.StorageStrategy
-import androidx.recyclerview.widget.LinearLayoutManager
-import com.github.dhaval2404.colorpicker.MaterialColorPickerDialog
-import com.github.dhaval2404.colorpicker.model.ColorShape
-import dagger.hilt.android.AndroidEntryPoint
-import de.psdev.devdrawer.BaseFragment
-import de.psdev.devdrawer.R
-import de.psdev.devdrawer.database.DevDrawerDatabase
-import de.psdev.devdrawer.database.WidgetProfile
-import de.psdev.devdrawer.databinding.FragmentWidgetEditBinding
-import de.psdev.devdrawer.profiles.WidgetProfilesDetailsLookup
-import de.psdev.devdrawer.profiles.WidgetProfilesItemKeyProvider
-import de.psdev.devdrawer.profiles.WidgetProfilesListAdapter
-import de.psdev.devdrawer.receivers.UpdateReceiver
-import de.psdev.devdrawer.utils.awaitSubmit
-import de.psdev.devdrawer.utils.receiveClicksFrom
-import de.psdev.devdrawer.utils.receiveTextChangesFrom
-import de.psdev.devdrawer.utils.sortColorList
-import de.psdev.devdrawer.widgets.EditWidgetFragmentViewModel.Selection
-import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class EditWidgetFragment : BaseFragment() {
-
- // Dependencies
- @Inject
- lateinit var devDrawerDatabase: DevDrawerDatabase
-
- @Inject
- lateinit var viewModelViewModelFactory: EditWidgetFragmentViewModel.ViewModelFactory
-
- val args by navArgs()
-
- val viewModel: EditWidgetFragmentViewModel by viewModels {
- EditWidgetFragmentViewModel.factory(viewModelViewModelFactory, args.widgetId)
- }
-
- var _selectionTracker: SelectionTracker? = null
-
- override fun createViewBinding(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): FragmentWidgetEditBinding =
- FragmentWidgetEditBinding.inflate(inflater, container, false)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- val adapter = WidgetProfilesListAdapter()
- adapter.itemLongClickListener = { widgetProfile ->
- findNavController().navigate(EditWidgetFragmentDirections.createProfileAction(widgetProfile.id))
- }
- // Setup views
- with(binding) {
- with(editName) {
- setText("Widget ${args.widgetId}")
- }
- with(btnColor) {
- setOnClickListener {
- val currentColor = viewModel.savedWidget.value?.color ?: Color.BLACK
- MaterialColorPickerDialog
- .Builder(requireContext())
- .setTitle(R.string.pick_widget_color)
- .setDefaultColor(currentColor)
- .setColorShape(ColorShape.SQAURE)
- .setColorRes(resources.getIntArray(R.array.widget_colors).sortColorList())
- .setPositiveButton(R.string.ok)
- .setNegativeButton(R.string.cancel)
- .setColorListener { color, _ ->
- setBackgroundColor(color)
- viewModel.inputColor.value = color
- }
- .showBottomSheet(childFragmentManager)
- }
- }
- }
- lifecycleScope.launchWhenResumed {
- with(binding) {
- val widget = checkNotNull(viewModel.savedWidget.filterNotNull().first())
- editName.setText(widget.name)
- btnColor.setBackgroundColor(widget.color)
- }
- }
-
- binding.btnNewProfile.setOnClickListener {
- lifecycleScope.launchWhenResumed {
- val widgetProfile = WidgetProfile(name = "Profile for ${viewModel.inputWidgetName.value}")
- devDrawerDatabase.widgetProfileDao().insert(widgetProfile)
- findNavController().navigate(EditWidgetFragmentDirections.createProfileAction(widgetProfile.id))
- }
- }
-
- binding.recyclerProfiles.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
- binding.recyclerProfiles.adapter = adapter
- val selectionTracker = SelectionTracker.Builder(
- "widgetProfile",
- binding.recyclerProfiles,
- WidgetProfilesItemKeyProvider(adapter),
- WidgetProfilesDetailsLookup(binding.recyclerProfiles),
- StorageStrategy.createStringStorage()
- ).withSelectionPredicate(
- SelectionPredicates.createSelectSingleAnything()
- ).build().also {
- it.onRestoreInstanceState(savedInstanceState)
- if (savedInstanceState == null) {
- lifecycleScope.launchWhenResumed {
- it.select(devDrawerDatabase.widgetDao().findById(args.widgetId)?.profileId.orEmpty())
- }
- }
- _selectionTracker = it
- }
- adapter.selectionTracker = selectionTracker
-
- viewLifecycleScope.launch {
- viewModel.inputWidgetName.receiveTextChangesFrom(binding.editName).launchIn(this)
-
- selectionTracker.addObserver(object : SelectionTracker.SelectionObserver() {
- override fun onSelectionChanged() {
- super.onSelectionChanged()
- val widgetProfile = selectionTracker.selection.asSequence()
- .map { selectedKey -> adapter.currentList.firstOrNull { it.id == selectedKey } }.firstOrNull()
- if (widgetProfile != null) {
- viewModel.inputSelectedProfile.value = Selection.Profile(widgetProfile)
- } else {
- viewModel.inputSelectedProfile.value = Selection.Nothing
- }
- }
- })
- viewModel.inputSaveTrigger.receiveClicksFrom(binding.btnConfirm).launchIn(this)
- viewModel.outputWidgetProfiles.onEach {
- adapter.awaitSubmit(it)
- binding.txtNoProfiles.isVisible = it.isEmpty()
- }.launchIn(this)
- viewModel.outputFormCompleted.onEach { completed ->
- if (completed) {
- with(binding.btnConfirm) {
- isEnabled = true
- setText(R.string.save)
- }
- } else {
- with(binding.btnConfirm) {
- isEnabled = false
- text = "Select profile"
- }
- }
- }.launchIn(this)
- viewModel.outputCloseTrigger.onEach { widget ->
- val resultValue = Intent().apply {
- putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget.id)
- }
- requireActivity().setResult(Activity.RESULT_OK, resultValue)
- // Will either close the fragment or finish the activity when it's the last activity
- if (!findNavController().popBackStack()) {
- requireActivity().finish()
- }
- UpdateReceiver.send(requireContext())
- }.launchIn(this)
- }
- }
-
- override fun onResume() {
- super.onResume()
- updateToolbarTitle(R.string.edit_widget)
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(outState)
- _selectionTracker?.onSaveInstanceState(outState)
- }
-
- override fun onDestroyView() {
- binding.recyclerProfiles.adapter = null
- super.onDestroyView()
- }
-
- // TODO Default name: Widget
- // TODO After losing focus of text input update name in viewState
-
-}
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt
deleted file mode 100644
index df82a4a4..00000000
--- a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-package de.psdev.devdrawer.widgets
-
-import android.graphics.Color
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider.Factory
-import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
-import de.psdev.devdrawer.database.DevDrawerDatabase
-import de.psdev.devdrawer.database.Widget
-import de.psdev.devdrawer.database.WidgetProfile
-import de.psdev.devdrawer.utils.simpleFactory
-import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.launch
-import mu.KLogging
-
-class EditWidgetFragmentViewModel @AssistedInject constructor(
- private val database: DevDrawerDatabase,
- @Assisted private val widgetId: Int
-) : ViewModel() {
-
- companion object : KLogging() {
- fun factory(
- viewModelFactory: ViewModelFactory,
- widgetId: Int
- ): Factory = simpleFactory {
- viewModelFactory.create(widgetId)
- }
- }
-
- // Inputs
- val inputWidgetName = MutableStateFlow("")
- val inputColor = MutableStateFlow(Color.BLACK)
- val inputSelectedProfile = MutableStateFlow(Selection.Nothing)
- val inputSaveTrigger = MutableSharedFlow(1)
-
- // Outputs
- val outputWidgetProfiles
- get() = database.widgetProfileDao().findAllFlow()
-
- val outputFormCompleted = combine(inputWidgetName, inputSelectedProfile) { name, selection ->
- name.isNotBlank() && selection is Selection.Profile
- }
-
- // TODO add sealed class for success / cancel
- val outputCloseTrigger = MutableSharedFlow(1)
-
- val savedWidget: MutableStateFlow = MutableStateFlow(null)
-
- init {
- viewModelScope.launch {
- savedWidget.value = database.widgetDao().findById(widgetId)?.also { widget ->
- inputWidgetName.value = widget.name
- inputColor.value = widget.color
- }
- }
- combine(inputWidgetName, inputColor) { name, color ->
- logger.info { "Update savedWidget: $name, $color" }
- savedWidget.value?.let { widget ->
- widget.name = name
- widget.color = color
- }
- }.launchIn(viewModelScope)
- inputSaveTrigger.asSharedFlow().flatMapLatest {
- combine(
- inputWidgetName,
- inputColor,
- inputSelectedProfile.filterIsInstance()
- ) { name, color, selection ->
- savedWidget.value?.copy(
- name = name,
- color = color,
- profileId = selection.profile.id
- ) ?: Widget(
- id = widgetId,
- name = name,
- color = color,
- profileId = selection.profile.id
- )
- }
- }.onEach { widget ->
- database.widgetDao().insertOrUpdate(widget)
- savedWidget.value = widget
- outputCloseTrigger.emit(widget)
- }.launchIn(viewModelScope)
- }
-
- sealed class Selection {
- object Nothing : Selection()
- data class Profile(val profile: WidgetProfile) : Selection()
- }
-
- @AssistedFactory
- interface ViewModelFactory {
- fun create(widgetId: Int): EditWidgetFragmentViewModel
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/SaveWidgetWorker.kt b/app/src/main/java/de/psdev/devdrawer/widgets/SaveWidgetWorker.kt
new file mode 100644
index 00000000..5f8ecec4
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/SaveWidgetWorker.kt
@@ -0,0 +1,49 @@
+package de.psdev.devdrawer.widgets
+
+import android.content.Context
+import android.graphics.Color
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import de.psdev.devdrawer.database.DevDrawerDatabase
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.database.WidgetProfile
+import de.psdev.devdrawer.receivers.UpdateReceiver
+import mu.KLogging
+
+@HiltWorker
+class SaveWidgetWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted params: WorkerParameters,
+ private val database: DevDrawerDatabase
+) : CoroutineWorker(appContext, params) {
+
+ companion object : KLogging() {
+ const val ARG_WIDGET_ID = "widgetId"
+ const val INVALID_WIDGET_ID = -1
+ }
+
+ override suspend fun doWork(): Result {
+ val widgetId = inputData.getInt(ARG_WIDGET_ID, INVALID_WIDGET_ID)
+ check(widgetId != INVALID_WIDGET_ID) { "Invalid widget ID" }
+ val widgetDao = database.widgetDao()
+ val widgetProfileDao = database.widgetProfileDao()
+ val defaultWidgetProfile = widgetProfileDao.findAll().firstOrNull()
+ ?: WidgetProfile(name = "Default").also {
+ widgetProfileDao.insert(it)
+ }
+
+ // Create entries in database
+ val widget = Widget(
+ id = widgetId,
+ name = "Widget $widgetId",
+ color = Color.BLACK,
+ profileId = defaultWidgetProfile.id
+ )
+ widgetDao.insert(widget)
+ UpdateReceiver.send(applicationContext)
+ return Result.success()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt
deleted file mode 100644
index 278e2157..00000000
--- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package de.psdev.devdrawer.widgets
-
-import android.app.Activity
-import android.appwidget.AppWidgetManager
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import androidx.navigation.findNavController
-import androidx.navigation.ui.AppBarConfiguration
-import androidx.navigation.ui.setupWithNavController
-import dagger.hilt.android.AndroidEntryPoint
-import de.psdev.devdrawer.BaseActivity
-import de.psdev.devdrawer.R
-import de.psdev.devdrawer.analytics.Events
-import de.psdev.devdrawer.database.DevDrawerDatabase
-import de.psdev.devdrawer.databinding.ActivityWidgetConfigBinding
-import mu.KLogging
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class WidgetConfigActivity : BaseActivity() {
-
- companion object : KLogging() {
- fun createStartIntent(context: Context, appWidgetId: Int): Intent =
- Intent(context, WidgetConfigActivity::class.java).apply {
- putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
- }
- }
-
- // Dependencies
- @Inject
- lateinit var devDrawerDatabase: DevDrawerDatabase
-
- private lateinit var binding: ActivityWidgetConfigBinding
-
- // ==========================================================================================================================
- // Activity Lifecycle
- // ==========================================================================================================================
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- // Set the result to CANCELED. This will cause the widget host to cancel
- // out of the widget placement if they press the back button.
- setResult(RESULT_CANCELED)
-
- val widgetId = getWidgetId()
- if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
- finish()
- return
- }
- if (intent.getBooleanExtra("from_widget", false)) {
- trackingService.trackAction(Events.EVENT_WIDGET_OPEN_SETTINGS)
- }
-
- binding = ActivityWidgetConfigBinding.inflate(layoutInflater)
- setContentView(binding.root)
- setSupportActionBar(binding.toolbar)
-
- val navController = findNavController(R.id.nav_host_fragment)
- navController.setGraph(R.navigation.nav_config_widget, EditWidgetFragmentArgs(widgetId).toBundle())
- val appBarConfiguration = AppBarConfiguration(navController.graph)
- binding.toolbar.setupWithNavController(navController, appBarConfiguration)
- }
-
- // ==========================================================================================================================
- // Public API
- // ==========================================================================================================================
-
- override fun onBackPressed() {
- setResult(Activity.RESULT_CANCELED, null)
- super.onBackPressed()
- }
-
- private fun getWidgetId(): Int =
- intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
- ?: AppWidgetManager.INVALID_APPWIDGET_ID
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetListFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetListFragment.kt
deleted file mode 100644
index e4aafdab..00000000
--- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetListFragment.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-package de.psdev.devdrawer.widgets
-
-import android.app.PendingIntent
-import android.appwidget.AppWidgetManager
-import android.content.ComponentName
-import android.content.Intent
-import android.os.Build
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.annotation.RequiresApi
-import androidx.core.content.getSystemService
-import androidx.core.os.bundleOf
-import androidx.core.view.isVisible
-import androidx.lifecycle.lifecycleScope
-import androidx.navigation.fragment.findNavController
-import dagger.hilt.android.AndroidEntryPoint
-import de.psdev.devdrawer.BaseFragment
-import de.psdev.devdrawer.R
-import de.psdev.devdrawer.appwidget.DDWidgetProvider
-import de.psdev.devdrawer.database.DevDrawerDatabase
-import de.psdev.devdrawer.database.Widget
-import de.psdev.devdrawer.databinding.FragmentWidgetListBinding
-import de.psdev.devdrawer.utils.Constants
-import de.psdev.devdrawer.utils.awaitSubmit
-import de.psdev.devdrawer.utils.supportsVersion
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import mu.KLogging
-import javax.inject.Inject
-
-
-@AndroidEntryPoint
-class WidgetListFragment : BaseFragment() {
-
- companion object : KLogging()
-
- @Inject
- lateinit var devDrawerDatabase: DevDrawerDatabase
-
- override fun createViewBinding(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): FragmentWidgetListBinding = FragmentWidgetListBinding.inflate(inflater, container, false)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- val clickListener: (Widget) -> Unit = { widget ->
- findNavController().navigate(WidgetListFragmentDirections.editWidget(widget.id))
- }
- val listAdapter = WidgetsListAdapter(clickListener)
- with(binding) {
- recyclerWidgets.adapter = listAdapter
- }
- devDrawerDatabase.widgetDao().findAllFlow().onEach {
- listAdapter.awaitSubmit(it)
- binding.containerNoWidgets.isVisible = it.isEmpty()
- supportsVersion(Build.VERSION_CODES.O) {
- with(binding.btnAddWidget) {
- isVisible = true
- setOnClickListener {
- requestAppWidgetPinning()
- }
- }
- }
- }.launchIn(lifecycleScope)
- }
-
- override fun onResume() {
- super.onResume()
- updateToolbarTitle(R.string.widgets)
- }
-
- override fun onDestroyView() {
- binding.recyclerWidgets.adapter = null
- super.onDestroyView()
- }
-
- @RequiresApi(Build.VERSION_CODES.O)
- private fun requestAppWidgetPinning() {
- val activity = requireActivity()
- val appWidgetManager: AppWidgetManager = activity.getSystemService() ?: return
- val widgetProvider = ComponentName(activity, DDWidgetProvider::class.java)
- if (appWidgetManager.isRequestPinAppWidgetSupported) {
- val pinnedWidgetCallbackIntent = Intent(activity, DDWidgetProvider::class.java).apply {
- action = Constants.ACTION_WIDGET_PINNED
- }
- val successCallback = PendingIntent.getBroadcast(
- activity,
- 1,
- pinnedWidgetCallbackIntent,
- PendingIntent.FLAG_UPDATE_CURRENT
- )
- val bundle = bundleOf()
- appWidgetManager.requestPinAppWidget(widgetProvider, bundle, successCallback)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt
new file mode 100644
index 00000000..f513e230
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt
@@ -0,0 +1,23 @@
+package de.psdev.devdrawer.widgets
+
+import android.app.Application
+import de.psdev.devdrawer.database.DevDrawerDatabase
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.receivers.UpdateReceiver
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WidgetRepository @Inject constructor(
+ private val application: Application,
+ private val devDrawerDatabase: DevDrawerDatabase
+) {
+
+ fun widgetFlow(widgetId: Int) = devDrawerDatabase.widgetDao().widgetWithIdObservable(widgetId)
+
+ suspend fun update(widget: Widget) {
+ devDrawerDatabase.widgetDao().update(widget)
+ UpdateReceiver.send(application)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetsListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetsListAdapter.kt
deleted file mode 100644
index 50480379..00000000
--- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetsListAdapter.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package de.psdev.devdrawer.widgets
-
-import android.view.ViewGroup
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
-import de.psdev.devdrawer.database.Widget
-import de.psdev.devdrawer.databinding.ListItemWidgetBinding
-import de.psdev.devdrawer.utils.layoutInflater
-import mu.KLogging
-
-class WidgetsListAdapter(
- private val clickListener: (Widget) -> Unit
-): ListAdapter(Widget.DIFF_CALLBACK) {
-
- companion object: KLogging()
-
- // ==========================================================================================================================
- // RecyclerView.Adapter
- // ==========================================================================================================================
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WidgetsListViewHolder =
- WidgetsListViewHolder(
- ListItemWidgetBinding.inflate(parent.layoutInflater, parent, false),
- clickListener
- )
-
- override fun onBindViewHolder(holder: WidgetsListViewHolder, position: Int) {
- holder.bindTo(getItem(position))
- }
-
- // ==========================================================================================================================
- // WidgetsListViewHolder
- // ==========================================================================================================================
-
- class WidgetsListViewHolder(
- private val binding: ListItemWidgetBinding,
- private val clickListener: (Widget) -> Unit
- ): RecyclerView.ViewHolder(binding.root) {
-
- fun bindTo(widget: Widget) {
- binding.txtName.text = widget.name
- itemView.setOnClickListener { clickListener(widget) }
- }
- }
-}
-
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt
new file mode 100644
index 00000000..c3085dd6
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt
@@ -0,0 +1,60 @@
+package de.psdev.devdrawer.widgets
+
+import android.content.res.Configuration
+import android.graphics.Color
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+fun WidgetCard(
+ widget: Widget,
+ onWidgetClick: (Widget) -> Unit = {}
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .padding(8.dp)
+ .clickable { onWidgetClick(widget) }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .padding(16.dp)
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ style = MaterialTheme.typography.bodyMedium,
+ text = widget.name
+ )
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ style = MaterialTheme.typography.bodySmall,
+ text = stringResource(id = R.string.widget_id_template, widget.id)
+ )
+ }
+ }
+}
+
+@Preview
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetCard() {
+ DevDrawerTheme {
+ WidgetCard(widget = Widget(1, "Test Widget", Color.BLACK, ""))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetConfigActivity.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetConfigActivity.kt
new file mode 100644
index 00000000..0c2542cf
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetConfigActivity.kt
@@ -0,0 +1,108 @@
+package de.psdev.devdrawer.widgets.ui
+
+import android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID
+import android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dagger.hilt.android.AndroidEntryPoint
+import de.psdev.devdrawer.BaseActivity
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.analytics.Events
+import de.psdev.devdrawer.database.DevDrawerDatabase
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import de.psdev.devdrawer.widgets.ui.editor.WidgetEditor
+import de.psdev.devdrawer.widgets.ui.editor.WidgetEditorViewModel
+import de.psdev.devdrawer.widgets.ui.editor.WidgetEditorViewState
+import mu.KLogging
+import javax.inject.Inject
+
+@OptIn(ExperimentalMaterial3Api::class)
+@AndroidEntryPoint
+class WidgetConfigActivity : BaseActivity() {
+
+ companion object : KLogging() {
+ fun createStartIntent(context: Context, appWidgetId: Int): Intent =
+ Intent(context, WidgetConfigActivity::class.java).apply {
+ putExtra(EXTRA_APPWIDGET_ID, appWidgetId)
+ }
+ }
+
+ // Dependencies
+ @Inject
+ lateinit var devDrawerDatabase: DevDrawerDatabase
+
+ // ==========================================================================================================================
+ // Activity Lifecycle
+ // ==========================================================================================================================
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Set the result to CANCELED. This will cause the widget host to cancel
+ // out of the widget placement if they press the back button.
+ setResult(RESULT_CANCELED, null)
+
+ val widgetId = getWidgetId()
+ if (widgetId == INVALID_APPWIDGET_ID) {
+ finish()
+ return
+ }
+ if (intent.getBooleanExtra("from_widget", false)) {
+ trackingService.trackAction(Events.EVENT_WIDGET_OPEN_SETTINGS)
+ }
+ // Manually fill Intent for SavedStateHandle
+ intent.putExtra("id", widgetId)
+
+ setContent {
+ DevDrawerTheme {
+ val viewModel = hiltViewModel()
+ val viewState by viewModel.state.collectAsStateWithLifecycle(
+ initialValue = WidgetEditorViewState.Empty
+ )
+ Surface(color = MaterialTheme.colorScheme.background) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ modifier = Modifier.statusBarsPadding(),
+ title = {
+ Text(text = stringResource(R.string.edit_widget))
+ }
+ )
+ },
+ content = { paddingValues ->
+ WidgetEditor(
+ modifier = Modifier.padding(paddingValues),
+ viewState = viewState,
+ onNameChange = viewModel::onNameChanged,
+ onColorSelected = viewModel::onWidgetColorChanged,
+ onSaveChangesClick = viewModel::saveChanges,
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+
+ // ==========================================================================================================================
+ // Public API
+ // ==========================================================================================================================
+
+ private fun getWidgetId(): Int =
+ intent?.getIntExtra(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) ?: INVALID_APPWIDGET_ID
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetsDirections.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetsDirections.kt
new file mode 100644
index 00000000..060215b6
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetsDirections.kt
@@ -0,0 +1,28 @@
+package de.psdev.devdrawer.widgets.ui
+
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavType
+import androidx.navigation.navArgument
+import de.psdev.devdrawer.NavigationCommand
+
+object WidgetsDirections {
+ val root = object : NavigationCommand {
+ override val arguments: List = emptyList()
+ override val route: String = "widgets"
+ }
+ val list = object : NavigationCommand {
+ override val arguments: List = emptyList()
+ override val route: String = "widgets/list"
+ }
+ val edit = object : NavigationCommand {
+ override val arguments: List = listOf(
+ navArgument(
+ name = "profileId"
+ ) {
+ type = NavType.StringType
+ nullable = false
+ }
+ )
+ override val route: String = "profiles/{profileId}"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetsNavGraph.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetsNavGraph.kt
new file mode 100644
index 00000000..cc69a919
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetsNavGraph.kt
@@ -0,0 +1,32 @@
+package de.psdev.devdrawer.widgets.ui
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.navigation
+import de.psdev.devdrawer.ProfileEditorDestination
+import de.psdev.devdrawer.profiles.WidgetProfileDirections
+import de.psdev.devdrawer.profiles.ui.editor.WidgetProfileEditor
+import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesScreen
+
+fun NavGraphBuilder.widgetsGraph(navController: NavController) {
+ navigation(
+ route = WidgetProfileDirections.root.route,
+ startDestination = WidgetProfileDirections.list.route
+ ) {
+ composable(
+ route = WidgetProfileDirections.list.route,
+ arguments = WidgetProfileDirections.list.arguments
+ ) {
+ WidgetProfilesScreen(
+ editProfile = { navController.navigate(ProfileEditorDestination(it).route) }
+ )
+ }
+ composable(
+ route = WidgetProfileDirections.edit.route,
+ arguments = WidgetProfileDirections.edit.arguments
+ ) {
+ WidgetProfileEditor()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt
new file mode 100644
index 00000000..9495e6d4
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt
@@ -0,0 +1,101 @@
+package de.psdev.devdrawer.widgets.ui.editor
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+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.RoundedCornerShape
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+fun ColorGrid(
+ initialColor: Int,
+ onColorClicked: (Int) -> Unit = {}
+) {
+ var selectedColor by remember { mutableStateOf(initialColor) }
+ val colors = listOf(
+ android.graphics.Color.BLACK,
+ android.graphics.Color.DKGRAY,
+ android.graphics.Color.GRAY,
+ android.graphics.Color.LTGRAY,
+ android.graphics.Color.WHITE,
+ android.graphics.Color.RED,
+ android.graphics.Color.GREEN,
+ android.graphics.Color.BLUE,
+ android.graphics.Color.YELLOW,
+ android.graphics.Color.CYAN,
+ android.graphics.Color.MAGENTA
+ )
+ LazyVerticalGrid(
+ modifier = Modifier.wrapContentHeight(),
+ columns = GridCells.Adaptive(minSize = 64.dp),
+ contentPadding = PaddingValues(8.dp)
+ ) {
+ items(colors) { color ->
+ val isSelectedColor = color == selectedColor
+ ColorBox(
+ isSelectedColor = isSelectedColor,
+ color = color
+ ) {
+ selectedColor = it
+ onColorClicked(it)
+ }
+ }
+ }
+}
+
+@Composable
+fun ColorBox(
+ modifier: Modifier = Modifier,
+ isSelectedColor: Boolean,
+ color: Int,
+ onColorClicked: (Int) -> Unit
+) {
+ val cornerSize by animateDpAsState(
+ targetValue = if (isSelectedColor) 8.dp else 0.dp
+ )
+ val borderWidth by animateDpAsState(
+ targetValue = if (isSelectedColor) 2.dp else 1.dp
+ )
+ val borderColor by animateColorAsState(
+ targetValue = Color(if (isSelectedColor) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
+ )
+ val shape = RoundedCornerShape(
+ size = cornerSize
+ )
+ Box(modifier = modifier
+ .padding(8.dp)
+ .requiredSize(48.dp)
+ .clip(shape)
+ .border(
+ width = borderWidth,
+ color = borderColor,
+ shape = shape
+ )
+ .background(Color(color), shape = shape)
+ .clickable {
+ onColorClicked(color)
+ }
+ )
+}
+
+@Preview
+@Composable
+fun Preview_ColorGrid() {
+ DevDrawerTheme {
+ ColorGrid(
+ initialColor = android.graphics.Color.BLACK
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt
new file mode 100644
index 00000000..5be8dd6c
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt
@@ -0,0 +1,64 @@
+package de.psdev.devdrawer.widgets.ui.editor
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.*
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+
+@Composable
+fun ColorSelectionDialog(
+ initialColor: Int,
+ onColorSelectionChanged: (Int) -> Unit = {},
+ onColorSelected: (Int) -> Unit = {},
+ onDismiss: () -> Unit = {}
+) {
+ var selectedColor by remember { mutableStateOf(initialColor) }
+ AlertDialog(
+ onDismissRequest = { },
+ title = {
+ Text(text = "Select color")
+ },
+ text = {
+ ColorGrid(
+ initialColor = initialColor,
+ onColorClicked = {
+ selectedColor = it
+ onColorSelectionChanged(it)
+ }
+ )
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ onDismiss()
+ }) {
+ Text(stringResource(id = R.string.cancel))
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ onColorSelected(selectedColor)
+ }) {
+ Text(stringResource(id = R.string.apply))
+ }
+ }
+ )
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_ColorSelectionDialog() {
+ DevDrawerTheme {
+ Column {
+ ColorSelectionDialog(
+ initialColor = android.graphics.Color.BLACK
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt
new file mode 100644
index 00000000..debda761
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt
@@ -0,0 +1,275 @@
+package de.psdev.devdrawer.widgets.ui.editor
+
+import android.content.res.Configuration
+import androidx.compose.animation.*
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.outlined.Save
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import de.psdev.devdrawer.AppBarMenuCallback
+import de.psdev.devdrawer.ProvideMenu
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.database.WidgetProfile
+import de.psdev.devdrawer.destinations.WidgetProfileEditorDestination
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import mu.KotlinLogging
+import java.util.*
+
+data class WidgetEditorScreenNavArgs(
+ val id: Int
+)
+
+private val logger = KotlinLogging.logger { }
+
+@Composable
+@Destination(navArgsDelegate = WidgetEditorScreenNavArgs::class)
+fun WidgetEditorScreen(
+ viewModel: WidgetEditorViewModel = hiltViewModel(),
+ menuCallback: AppBarMenuCallback,
+ navigator: DestinationsNavigator,
+ onChangesSaved: (Widget) -> Unit = {}
+) {
+ val viewState by viewModel.state.collectAsStateWithLifecycle(
+ initialValue = WidgetEditorViewState.Empty
+ )
+
+ logger.warn { "State: $viewState" }
+
+ val persistedWidget = viewState.persistedWidget
+ ProvideMenu(menuCallback, persistedWidget) {
+ logger.warn { "Recomposing menu for $persistedWidget" }
+ if (persistedWidget != null) {
+ logger.warn { "Menu Icon" }
+ IconButton(onClick = {
+ // TODO Delete
+ navigator.popBackStack()
+ }) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null
+ )
+ }
+ }
+ }
+ WidgetEditor(
+ viewState = viewState,
+ onNameChange = viewModel::onNameChanged,
+ onColorSelected = { color ->
+ viewModel.onWidgetColorChanged(color)
+ },
+ onEditWidgetProfile = {
+ navigator.navigate(WidgetProfileEditorDestination(it.id))
+ },
+ onWidgetProfileSelected = viewModel::onWidgetProfileSelected,
+ onSaveChangesClick = {
+ viewModel.saveChanges()
+ persistedWidget?.let(onChangesSaved)
+ }
+ )
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun WidgetEditor(
+ modifier: Modifier = Modifier,
+ viewState: WidgetEditorViewState,
+ onNameChange: (String) -> Unit = {},
+ onColorSelected: (Int) -> Unit = {},
+ onEditWidgetProfile: (WidgetProfile) -> Unit = {},
+ onWidgetProfileSelected: (WidgetProfile) -> Unit = {},
+ onSaveChangesClick: () -> Unit = {}
+) {
+ val widget = viewState.editableWidget
+ if (widget == null) {
+ // Loading
+ Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(modifier = Modifier.size(64.dp))
+ }
+ } else {
+ var dialogState by remember {
+ mutableStateOf(
+ WidgetEditorDialogsState.None
+ )
+ }
+ Box(modifier = modifier.fillMaxSize()) {
+ Column {
+ Surface(modifier = Modifier.wrapContentHeight(), shadowElevation = 2.dp) {
+ Column(
+ modifier = Modifier
+ .wrapContentHeight()
+ .padding(8.dp)
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ value = widget.name,
+ onValueChange = onNameChange,
+ label = { Text(text = stringResource(id = R.string.name)) }
+ )
+ ColorBox(isSelectedColor = true, color = widget.color) {
+ dialogState = WidgetEditorDialogsState.ColorSelection(widget.color)
+ }
+ }
+ }
+ }
+ LazyColumn(
+ modifier = Modifier.weight(1f),
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(viewState.widgetProfiles) { widgetProfile ->
+ val backgroundColor by animateColorAsState(
+ targetValue = if (widget.profileId == widgetProfile.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
+ label = "background",
+ )
+ Card(
+ modifier = Modifier.combinedClickable(
+ onLongClick = {
+ onEditWidgetProfile(widgetProfile)
+ },
+ onClick = { onWidgetProfileSelected(widgetProfile) }
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(vertical = 16.dp, horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(modifier = Modifier.weight(1f), text = widgetProfile.name)
+ IconButton(onClick = {
+ onEditWidgetProfile(widgetProfile)
+ }) {
+ Icon(
+ imageVector = Icons.Filled.Edit,
+ contentDescription = stringResource(id = R.string.edit_profile)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ AnimatedVisibility(
+ visible = viewState.persistedWidget != viewState.editableWidget,
+ modifier = Modifier.align(Alignment.BottomEnd),
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ FloatingActionButton(
+ onClick = onSaveChangesClick,
+ modifier = Modifier.padding(end = 16.dp, bottom = 16.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Save,
+ contentDescription = stringResource(id = R.string.save)
+ )
+ }
+ }
+ }
+ when (val state = dialogState) {
+ WidgetEditorDialogsState.None -> Unit
+ is WidgetEditorDialogsState.ColorSelection -> ColorSelectionDialog(
+ initialColor = state.currentColor,
+ onColorSelected = {
+ onColorSelected(it)
+ dialogState = WidgetEditorDialogsState.None
+ },
+ onDismiss = {
+ dialogState = WidgetEditorDialogsState.None
+ }
+ )
+ }
+ }
+}
+
+sealed class WidgetEditorDialogsState {
+ data object None : WidgetEditorDialogsState()
+ data class ColorSelection(
+ val currentColor: Int
+ ) : WidgetEditorDialogsState()
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetEditor_Loading() {
+ DevDrawerTheme {
+ WidgetEditor(
+ viewState = WidgetEditorViewState.Empty
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetEditor_Loaded() {
+ val widgetProfile = WidgetProfile(
+ id = UUID.randomUUID().toString(),
+ name = "Test widget profile"
+ )
+ val widget = Widget(
+ id = 1,
+ name = "Test widget",
+ color = android.graphics.Color.YELLOW,
+ profileId = widgetProfile.id
+ )
+ DevDrawerTheme {
+ WidgetEditor(
+ viewState = WidgetEditorViewState(
+ persistedWidget = widget,
+ widgetProfiles = listOf(widgetProfile),
+ editableWidget = widget
+ )
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetEditor_Loaded_Changed() {
+ val widgetProfile = WidgetProfile(
+ id = UUID.randomUUID().toString(),
+ name = "Test widget profile"
+ )
+ val widgetProfile2 = WidgetProfile(
+ id = UUID.randomUUID().toString(),
+ name = "Test widget profile 2"
+ )
+ val widget = Widget(
+ id = 1,
+ name = "Test widget",
+ color = android.graphics.Color.YELLOW,
+ profileId = widgetProfile.id
+ )
+ DevDrawerTheme {
+ WidgetEditor(
+ viewState = WidgetEditorViewState(
+ persistedWidget = widget,
+ widgetProfiles = listOf(widgetProfile, widgetProfile2),
+ editableWidget = widget.copy(profileId = widgetProfile2.id)
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt
new file mode 100644
index 00000000..8e654322
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt
@@ -0,0 +1,72 @@
+package de.psdev.devdrawer.widgets.ui.editor
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.database.WidgetProfile
+import de.psdev.devdrawer.navArgs
+import de.psdev.devdrawer.profiles.WidgetProfileRepository
+import de.psdev.devdrawer.widgets.WidgetRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class WidgetEditorViewModel @Inject constructor(
+ private val savedStateHandle: SavedStateHandle,
+ private val widgetRepository: WidgetRepository,
+ private val widgetProfileRepository: WidgetProfileRepository
+) : ViewModel() {
+ private val navArgs: WidgetEditorScreenNavArgs = savedStateHandle.navArgs()
+
+ private val editableWidgetState: MutableStateFlow = MutableStateFlow(null)
+
+ val state = combine(
+ widgetRepository.widgetFlow(navArgs.id),
+ widgetProfileRepository.widgetProfilesFlow(),
+ editableWidgetState
+
+ ) { persistedWidget, widgetProfiles, editableWidget ->
+ if (editableWidgetState.value == null) {
+ editableWidgetState.value = persistedWidget
+ }
+ WidgetEditorViewState(
+ persistedWidget = persistedWidget,
+ widgetProfiles = widgetProfiles,
+ editableWidget = editableWidget
+ )
+ }
+
+ fun onNameChanged(newName: String) {
+ editableWidgetState.value = editableWidgetState.value?.copy(
+ name = newName
+ )
+ }
+
+ fun onWidgetColorChanged(color: Int) {
+ editableWidgetState.value = editableWidgetState.value?.copy(
+ color = color
+ )
+ }
+
+ fun onWidgetProfileSelected(widgetProfile: WidgetProfile) {
+ editableWidgetState.value = editableWidgetState.value?.copy(
+ profileId = widgetProfile.id
+ )
+ }
+
+ fun saveChanges() {
+ editableWidgetState.value?.let {
+ viewModelScope.launch {
+ widgetRepository.update(it)
+ }
+ }
+ }
+
+ fun deleteWidget(widget: Widget) {
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt
new file mode 100644
index 00000000..0f48e329
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt
@@ -0,0 +1,16 @@
+package de.psdev.devdrawer.widgets.ui.editor
+
+import androidx.compose.runtime.Immutable
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.database.WidgetProfile
+
+@Immutable
+data class WidgetEditorViewState(
+ val persistedWidget: Widget? = null,
+ val editableWidget: Widget? = null,
+ val widgetProfiles: List = emptyList()
+) {
+ companion object {
+ val Empty = WidgetEditorViewState()
+ }
+}
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt
new file mode 100644
index 00000000..363fd38a
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt
@@ -0,0 +1,43 @@
+package de.psdev.devdrawer.widgets.ui.list
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import de.psdev.devdrawer.widgets.WidgetCard
+
+@Composable
+fun WidgetList(
+ widgets: List,
+ modifier: Modifier = Modifier,
+ onWidgetClick: (Widget) -> Unit = {},
+ contentPadding: PaddingValues = PaddingValues(0.dp)
+) {
+ LazyColumn(
+ modifier = modifier
+ .fillMaxWidth()
+ .fillMaxHeight(),
+ contentPadding = contentPadding,
+ ) {
+ items(widgets, key = { it.id }) { widget ->
+ WidgetCard(widget = widget, onWidgetClick = onWidgetClick)
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetList() {
+ DevDrawerTheme {
+ WidgetList(widgets = testWidgets())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt
new file mode 100644
index 00000000..c14acc2c
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt
@@ -0,0 +1,215 @@
+package de.psdev.devdrawer.widgets.ui.list
+
+import android.content.res.Configuration
+import android.graphics.Color
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Add
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.annotation.RootNavGraph
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import de.psdev.devdrawer.R
+import de.psdev.devdrawer.database.Widget
+import de.psdev.devdrawer.destinations.WidgetEditorScreenDestination
+import de.psdev.devdrawer.ui.theme.DevDrawerTheme
+import kotlinx.coroutines.flow.map
+import java.util.UUID
+
+@RootNavGraph(start = true)
+@Destination
+@Composable
+fun WidgetListScreen(
+ widgetListScreenViewModel: WidgetListScreenViewModel = hiltViewModel(),
+ navigator: DestinationsNavigator
+) {
+ val context = LocalContext.current
+ val state by remember {
+ widgetListScreenViewModel.widgets.map { widgets ->
+ WidgetListScreenState.Loaded(
+ widgets = widgets,
+ isRequestPinAppWidgetSupported = widgetListScreenViewModel.isRequestPinAppWidgetSupported(context)
+ )
+ }
+ }.collectAsState(initial = WidgetListScreenState.Loading)
+ WidgetListScreen(
+ state = state,
+ onWidgetClick = {
+ navigator.navigate(WidgetEditorScreenDestination(it.id))
+ },
+ onRequestPinWidgetClick = {
+ widgetListScreenViewModel.requestAppWidgetPinning(context)
+ }
+ )
+}
+
+@Composable
+fun WidgetListScreen(
+ state: WidgetListScreenState,
+ onWidgetClick: (Widget) -> Unit = {},
+ onRequestPinWidgetClick: () -> Unit = {}
+) {
+ when (state) {
+ WidgetListScreenState.Loading -> {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(64.dp))
+ Text(text = stringResource(id = R.string.loading))
+ }
+ }
+ is WidgetListScreenState.Loaded -> {
+ val widgets = state.widgets
+ if (widgets.isEmpty()) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ ) {
+ Text(
+ text = stringResource(id = R.string.no_widgets_created),
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ if (state.isRequestPinAppWidgetSupported) {
+ Spacer(modifier = Modifier.size(16.dp))
+ Button(onClick = onRequestPinWidgetClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_outline_add_box_24),
+ contentDescription = stringResource(id = R.string.add_widget)
+ )
+ Text(
+ modifier = Modifier.padding(start = 8.dp),
+ text = stringResource(id = R.string.add_widget)
+ )
+ }
+ }
+ }
+ } else {
+ Box(Modifier.fillMaxSize()) {
+ WidgetList(
+ widgets = widgets,
+ onWidgetClick = onWidgetClick,
+ contentPadding = PaddingValues(bottom = 80.dp)
+ )
+ if (state.isRequestPinAppWidgetSupported) {
+ FloatingActionButton(
+ onClick = onRequestPinWidgetClick,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 16.dp, bottom = 16.dp)
+ ) {
+ Icon(imageVector = Icons.Outlined.Add, contentDescription = "Pin new widget")
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+sealed class WidgetListScreenState {
+ data object Loading : WidgetListScreenState()
+ data class Loaded(
+ val widgets: List,
+ val isRequestPinAppWidgetSupported: Boolean = false
+ ) : WidgetListScreenState()
+}
+
+@Preview(name = "Loading", showSystemUi = true)
+@Preview(name = "Loading (Dark)", showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetListScreen_Loading() {
+ DevDrawerTheme {
+ WidgetListScreen(WidgetListScreenState.Loading)
+ }
+}
+
+@Preview(showSystemUi = true)
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetListScreen() {
+ DevDrawerTheme {
+ WidgetListScreen(WidgetListScreenState.Loaded(testWidgets()))
+ }
+}
+
+@Preview(name = "Empty", showSystemUi = true)
+@Preview(name = "Empty (Dark)", showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetListScreen_Empty() {
+ DevDrawerTheme {
+ WidgetListScreen(WidgetListScreenState.Loaded(emptyList()))
+ }
+}
+
+@Preview(name = "Empty", showSystemUi = true)
+@Preview(name = "Empty (Dark)", showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetListScreen_Empty_SupportsPinning() {
+ DevDrawerTheme {
+ WidgetListScreen(WidgetListScreenState.Loaded(emptyList(), true))
+ }
+}
+
+@Preview(name = "Not empty", showSystemUi = true)
+@Preview(name = "Not empty (Dark)", showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetListScreen_NotEmpty() {
+ DevDrawerTheme {
+ WidgetListScreen(WidgetListScreenState.Loaded(emptyList()))
+ }
+}
+
+@Preview(name = "Not empty", showSystemUi = true)
+@Preview(name = "Not empty (Dark)", showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun Preview_WidgetListScreen_NotEmpty_SupportsPinning() {
+ DevDrawerTheme {
+ WidgetListScreen(WidgetListScreenState.Loaded(emptyList(), true))
+ }
+}
+
+fun testWidgets(): List = listOf(
+ Widget(
+ id = 1,
+ name = "Test Widget",
+ color = Color.BLACK,
+ profileId = UUID.randomUUID().toString()
+ ),
+ Widget(
+ id = 2,
+ name = "Test Widget 2",
+ color = Color.BLACK,
+ profileId = UUID.randomUUID().toString()
+ )
+)
diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt
new file mode 100644
index 00000000..e7d7f34e
--- /dev/null
+++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt
@@ -0,0 +1,43 @@
+package de.psdev.devdrawer.widgets.ui.list
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import androidx.core.content.getSystemService
+import androidx.core.os.bundleOf
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.psdev.devdrawer.appwidget.DDWidgetProvider
+import de.psdev.devdrawer.database.DevDrawerDatabase
+import de.psdev.devdrawer.receivers.PinWidgetSuccessReceiver
+import javax.inject.Inject
+
+@HiltViewModel
+class WidgetListScreenViewModel @Inject constructor(
+ database: DevDrawerDatabase
+) : ViewModel() {
+
+ val widgets = database.widgetDao().findAllFlow()
+
+ fun isRequestPinAppWidgetSupported(
+ context: Context
+ ): Boolean = context.getSystemService()?.isRequestPinAppWidgetSupported == true
+
+ @SuppressLint("InlinedApi")
+ fun requestAppWidgetPinning(context: Context) {
+ val appWidgetManager: AppWidgetManager = context.getSystemService() ?: return
+ if (appWidgetManager.isRequestPinAppWidgetSupported) {
+ val widgetProvider = ComponentName(context, DDWidgetProvider::class.java)
+ val successCallback = PendingIntent.getBroadcast(
+ context,
+ 1,
+ PinWidgetSuccessReceiver.intent(context),
+ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_MUTABLE
+ )
+ val bundle = bundleOf()
+ appWidgetManager.requestPinAppWidget(widgetProvider, bundle, successCallback)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index df148b41..00000000
--- a/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_widget_config.xml b/app/src/main/res/layout/activity_widget_config.xml
deleted file mode 100644
index 70c55fbe..00000000
--- a/app/src/main/res/layout/activity_widget_config.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_widget_list.xml b/app/src/main/res/layout/fragment_widget_list.xml
deleted file mode 100644
index 2e0c0e47..00000000
--- a/app/src/main/res/layout/fragment_widget_list.xml
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_widget_profile_edit.xml b/app/src/main/res/layout/fragment_widget_profile_edit.xml
deleted file mode 100644
index f767f9e0..00000000
--- a/app/src/main/res/layout/fragment_widget_profile_edit.xml
+++ /dev/null
@@ -1,83 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_widget_profile_list.xml b/app/src/main/res/layout/fragment_widget_profile_list.xml
deleted file mode 100644
index 437e06d8..00000000
--- a/app/src/main/res/layout/fragment_widget_profile_list.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/layout/list_item_package_filter.xml b/app/src/main/res/layout/list_item_package_filter.xml
deleted file mode 100644
index 942bd821..00000000
--- a/app/src/main/res/layout/list_item_package_filter.xml
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/list_item_widget.xml b/app/src/main/res/layout/list_item_widget.xml
deleted file mode 100644
index 4adbe7d4..00000000
--- a/app/src/main/res/layout/list_item_widget.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_config_widget.xml b/app/src/main/res/navigation/nav_config_widget.xml
index cfda4f96..f6ebb6cf 100644
--- a/app/src/main/res/navigation/nav_config_widget.xml
+++ b/app/src/main/res/navigation/nav_config_widget.xml
@@ -8,31 +8,11 @@
-
-
-
-
-
-
-