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/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/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..cd3fc6b1 100644 --- a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt @@ -1,14 +1,50 @@ package de.psdev.devdrawer +import android.content.Context +import android.content.ContextWrapper import android.os.Bundle +import android.view.View +import android.view.ViewTreeObserver +import androidx.activity.compose.setContent +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +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.* +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.core.view.WindowCompat import androidx.lifecycle.lifecycleScope -import androidx.navigation.findNavController -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupWithNavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.navigation.dependency +import com.ramcosta.composedestinations.navigation.navigate +import com.ramcosta.composedestinations.spec.DirectionDestinationSpec import dagger.hilt.android.AndroidEntryPoint import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.databinding.ActivityMainBinding +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 kotlinx.coroutines.delay import mu.KLogging +import mu.KotlinLogging import javax.inject.Inject @AndroidEntryPoint @@ -16,8 +52,6 @@ class MainActivity : BaseActivity() { companion object : KLogging() - private lateinit var binding: ActivityMainBinding - @Inject lateinit var devDrawerDatabase: DevDrawerDatabase @@ -27,24 +61,220 @@ class MainActivity : BaseActivity() { 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) + WindowCompat.setDecorFitsSystemWindows(window, false) + setContent { + DevDrawerApp() + } + val start = System.currentTimeMillis() + val content: View = findViewById(android.R.id.content) + content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + // Check if the initial data is ready. + return if (System.currentTimeMillis() - start > 500L) { + // The content is ready; start drawing. + content.viewTreeObserver.removeOnPreDrawListener(this) + true + } else { + // The content is not ready; suspend. + false + } + } + }) + lifecycleScope.launchWhenCreated { + delay(600L) + content.postInvalidate() + } lifecycleScope.launchWhenResumed { trackingService.checkOptIn(this@MainActivity) } } +} + +val topLevelRoutes = listOf( + WidgetListScreenDestination, + WidgetProfilesScreenDestination, + SettingsScreenDestination +).map { it.route } + +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 + } + } + ) + } + } + } + ) + } + } +} + +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) +} + +@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) { +// +// } + } + +} + +tailrec fun Context.getActivity(): AppCompatActivity? = when (this) { + is AppCompatActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null +} +@Preview +@Composable +fun Preview_DevDrawerApp() { + DevDrawerApp() } 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..303f11d9 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/Navigation.kt @@ -0,0 +1,15 @@ +package de.psdev.devdrawer + +import androidx.annotation.StringRes +import de.psdev.devdrawer.destinations.* + +@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 + } + 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..08eafa0b 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt @@ -39,4 +39,11 @@ class MigrationFrom1To2( database.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(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `widget_profiles` ADD COLUMN `updatedAt` INTEGER NOT NULL DEFAULT 0;") + database.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/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/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 index 769ee55f..9f732042 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt @@ -1,89 +1,43 @@ 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 android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource 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.Widget 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 de.psdev.devdrawer.ui.theme.DevDrawerTheme import mu.KLogging import javax.inject.Inject @AndroidEntryPoint -class WidgetProfileListFragment: BaseFragment() { +class WidgetProfileListFragment: BaseFragment() { companion object: KLogging() - // Dependencies @Inject lateinit var devDrawerDatabase: DevDrawerDatabase - val listAdapter: WidgetProfilesListAdapter = WidgetProfilesListAdapter() - var _selectionTracker: SelectionTracker? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { - 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() { @@ -91,52 +45,39 @@ class WidgetProfileListFragment: BaseFragment( 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) - } - } +} + +@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)) } } - else -> super.onOptionsItemSelected(item) - } + ) +} - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - _selectionTracker?.onSaveInstanceState(outState) - } +sealed class DeleteDialogState { + data object Hidden : DeleteDialogState() + data class Showing( + val widgetProfile: WidgetProfile + ): DeleteDialogState() - override fun onDestroyView() { - _selectionTracker = null - listAdapter.selectionTracker = null - binding.recyclerProfiles.adapter = null - super.onDestroyView() - } + data class InUseError( + val widgetProfile: WidgetProfile, + val widgets: List + ): DeleteDialogState() } 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/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/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/WidgetConfigActivity.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetConfigActivity.kt similarity index 89% rename from app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt rename to app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetConfigActivity.kt index 278e2157..e0d5c58e 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetConfigActivity.kt @@ -1,4 +1,4 @@ -package de.psdev.devdrawer.widgets +package de.psdev.devdrawer.widgets.ui import android.app.Activity import android.appwidget.AppWidgetManager @@ -14,13 +14,14 @@ import de.psdev.devdrawer.R import de.psdev.devdrawer.analytics.Events import de.psdev.devdrawer.database.DevDrawerDatabase import de.psdev.devdrawer.databinding.ActivityWidgetConfigBinding +import de.psdev.devdrawer.widgets.ui.editor.WidgetEditFragmentArgs import mu.KLogging import javax.inject.Inject @AndroidEntryPoint -class WidgetConfigActivity : BaseActivity() { +class WidgetConfigActivity: BaseActivity() { - companion object : KLogging() { + companion object: KLogging() { fun createStartIntent(context: Context, appWidgetId: Int): Intent = Intent(context, WidgetConfigActivity::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) @@ -57,7 +58,10 @@ class WidgetConfigActivity : BaseActivity() { setSupportActionBar(binding.toolbar) val navController = findNavController(R.id.nav_host_fragment) - navController.setGraph(R.navigation.nav_config_widget, EditWidgetFragmentArgs(widgetId).toBundle()) + navController.setGraph( + R.navigation.nav_config_widget, + WidgetEditFragmentArgs(widgetId).toBundle() + ) val appBarConfiguration = AppBarConfiguration(navController.graph) binding.toolbar.setupWithNavController(navController, appBarConfiguration) } 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/EditWidgetFragmentViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/EditWidgetFragmentViewModel.kt similarity index 94% rename from app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt rename to app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/EditWidgetFragmentViewModel.kt index df82a4a4..92c6164e 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/EditWidgetFragmentViewModel.kt @@ -1,4 +1,4 @@ -package de.psdev.devdrawer.widgets +package de.psdev.devdrawer.widgets.ui.editor import android.graphics.Color import androidx.lifecycle.ViewModel @@ -11,10 +11,12 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import mu.KLogging +@OptIn(ExperimentalCoroutinesApi::class) class EditWidgetFragmentViewModel @AssistedInject constructor( private val database: DevDrawerDatabase, @Assisted private val widgetId: Int @@ -87,7 +89,7 @@ class EditWidgetFragmentViewModel @AssistedInject constructor( } sealed class Selection { - object Nothing : Selection() + data object Nothing : Selection() data class Profile(val profile: WidgetProfile) : Selection() } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditFragment.kt new file mode 100644 index 00000000..90d0ed27 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditFragment.kt @@ -0,0 +1,58 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import android.os.Bundle +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.BaseFragment +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import javax.inject.Inject + +@AndroidEntryPoint +class WidgetEditFragment: BaseFragment() { + + // Dependencies + @Inject + lateinit var devDrawerDatabase: DevDrawerDatabase + + @Inject + lateinit var viewModelViewModelFactory: EditWidgetFragmentViewModel.ViewModelFactory + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { +// WidgetEditor( +// onEditWidgetProfile = { widgetProfile -> +// findNavController().navigate(ProfileEditorDestination(widgetProfile).route) +// }, +// onChangesSaved = { +// val widgetId = requireActivity().intent.getIntExtra(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) +// val resultValue = Intent().apply { +// putExtra(EXTRA_APPWIDGET_ID, widgetId) +// } +// 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()) +// } +// ) + } + } + } + + override fun onResume() { + super.onResume() + updateToolbarTitle(R.string.edit_widget) + } + +} 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..2d3c9e6b --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt @@ -0,0 +1,274 @@ +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( + 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/WidgetListFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListFragment.kt new file mode 100644 index 00000000..bc3939b0 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListFragment.kt @@ -0,0 +1,68 @@ +package de.psdev.devdrawer.widgets.ui.list + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.core.content.getSystemService +import androidx.core.os.bundleOf +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.receivers.PinWidgetSuccessReceiver +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import mu.KLogging +import javax.inject.Inject + +@AndroidEntryPoint +class WidgetListFragment: BaseFragment() { + + companion object: KLogging() + + @Inject + lateinit var devDrawerDatabase: DevDrawerDatabase + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { +// WidgetListScreen( +// onWidgetClick = { widget -> +// findNavController().navigate(WidgetListFragmentDirections.editWidget(widget.id)) +// }, +// onRequestPinWidgetClick = ::requestAppWidgetPinning +// ) + } + } + } + + override fun onResume() { + super.onResume() + updateToolbarTitle(R.string.widgets) + } + + private fun requestAppWidgetPinning() { + val activity = requireActivity() + val appWidgetManager: AppWidgetManager = activity.getSystemService() ?: return + if (appWidgetManager.isRequestPinAppWidgetSupported) { + val widgetProvider = ComponentName(activity, DDWidgetProvider::class.java) + val successCallback = PendingIntent.getBroadcast( + activity, + 1, + PinWidgetSuccessReceiver.intent(activity), + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + 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/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..f0622fa8 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt @@ -0,0 +1,42 @@ +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/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 @@ - - - - - - - + android:name="de.psdev.devdrawer.widgets.ui.list.WidgetListFragment"> + android:name="de.psdev.devdrawer.profiles.WidgetProfileListFragment"> @@ -32,7 +30,7 @@ tools:layout="@layout/fragment_about" /> + + DevDrawer2 + Einstellungen + App info + Profile + Widgets + Speichern + Keine Profile + Neu + Name + Profil bearbeiten + Anwenden + Hinzufügen + Neu laden + Widget bearbeiten + Schließen + Information + App Signatur + Du kannst Profile bearbeiten, in dem du den Eintrag lange drückst + Löschen + Deinstallieren + App Details + App Signatur + Paketname + App Icon + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e4f04d46..8d40e035 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ DevDrawer2 - showActivityChoice + show_activity_choice false sort_order @@ -69,5 +69,24 @@ Cannot delete profile, still being used by widgets No Yes + ComposeActivity + + ID: %1$d + + + Create new profile + + + Loading… + Last modified + + + Show activity choice on launch + Widget Sorting Options + Opt-in to analytics + Clear + Delete profile? + No apps available or all already added as filter + More diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 673b4235..abff3ca2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -39,8 +39,12 @@ + + + +