diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c1e867ae..7fe268e9 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 @@ -31,3 +31,8 @@ jobs: run: chmod +x gradlew - name: Build with Gradle run: ./gradlew build + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: app/build/test-results/**/*.xml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3f5d42a4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## About the Project + +DevDrawer is an Android app (published on Google Play) that adds a home screen widget listing the developer's installed +apps for quick launching, uninstalling, and reinstalling. It supports multiple widgets with independent configurations, +app filtering by package name/signature/regex, and dark mode. + +- **Package**: `de.psdev.devdrawer` +- **Min SDK**: 26 | **Target/Compile SDK**: 36 +- **Language**: Kotlin | **JVM target**: 17 +- **Debug build suffix**: `.debug` (so debug and release can coexist on device) + +## Common Commands + +```bash +# Build +./gradlew build + +# Assemble debug APK +./gradlew assembleDebug + +# Run unit tests +./gradlew test + +# Run a single test class +./gradlew test --tests "de.psdev.devdrawer.SomeTest" + +# Lint +./gradlew lint + +# Check dependency updates +./gradlew dependencyUpdates + +# Print current version +./gradlew printVersion +``` + +There are no unit or instrumentation test source sets currently (no `src/test/` or `src/androidTest/` directories exist +yet). + +## Architecture + +The app follows **MVVM** with a Repository layer and uses **Jetpack Compose** for all UI. + +### Navigation + +Navigation uses **Jetpack Navigation 3** (`androidx.navigation3`). Routes are `@Serializable` data objects/classes +implementing `NavKey`, defined in `Routes.kt`. `DevDrawerHost.kt` maps each route to its screen composable via +`entryProvider { entry { ... } }` and renders them with `NavDisplay`. A `Navigator` (instantiated via +`remember { Navigator(navigationState) }` in `MainActivity`, passed down as a parameter) wraps the back stack mutations. +The root composable `DevDrawerApp` (`DevDrawerApp.kt`) owns the `Scaffold`, top bar, and bottom nav bar with three +top-level routes: `WidgetListRoute`, `WidgetProfilesRoute`, and `SettingsRoute`. `AboutRoute` and the detail routes +`WidgetEditorRoute(id)` / `WidgetProfileEditorRoute(id)` are also defined in `Routes.kt`. + +### Data Layer + +Room database (`DevDrawerDatabase`, version 3) with three entities and DAOs: + +| Entity | DAO | Purpose | +|-----------------|--------------------|-----------------------------------------------------------| +| `Widget` | `WidgetDao` | Home screen widget instances | +| `WidgetProfile` | `WidgetProfileDao` | Named filter profiles | +| `PackageFilter` | `PackageFilterDao` | Per-profile filter rules (package name, regex, signature) | + +DB schema migrations live in `Migrations.kt`. Room schema JSON exports go to `/schemas/`. + +### Dependency Injection + +Hilt throughout. Key modules: + +- `ApplicationModule` — provides `SharedPreferences` +- `DatabaseModule` — provides the Room DB and DAOs + +### Widget System + +- `DDWidgetProvider` — `AppWidgetProvider` that renders `RemoteViews`; **never rename this class** as it breaks existing + placed widgets +- `WidgetService` + `WidgetAppsListViewFactory` — `RemoteViewsService` that populates the scrollable app list inside the + widget +- `ClickHandlingActivity` — trampoline activity for widget item taps +- `UpdateWidgetsWorker` — `WorkManager` worker to refresh all widgets +- `AppInstallationReceiver` — `BroadcastReceiver` for `PACKAGE_ADDED`/`PACKAGE_REMOVED` events that triggers widget + refresh + +### Key Package Layout + +``` +de.psdev.devdrawer/ +├── appwidget/ # Widget provider, service, click handler +├── database/ # Room entities, DAOs, migrations +├── profiles/ # WidgetProfile feature (UI + repository) +│ └── ui/ +│ ├── editor/ # Profile editor screen & ViewModels +│ └── list/ # Profile list screen +├── receivers/ # Broadcast receivers +├── settings/ # Settings screen +├── ui/ # Shared Compose UI (theme, dialogs, loading) +├── utils/ # Extension functions +└── widgets/ # Widget config feature (UI + repository) + └── ui/ + ├── editor/ # Widget editor screen & ViewModel + └── list/ # Widget list screen +``` + + +## Build & Release + +- Signing config is read from `release.properties` (local) or CI env vars (`keystore_password`, `keystore_alias`, + `keystore_alias_password`) when `CI=true` +- `release.properties.sample` shows the expected format +- Google Play publishing via `com.github.triplet.play` plugin; requires `google-play-api.json` or + `ANDROID_PUBLISHER_CREDENTIALS` env var +- Firebase services require `google-services.json` in `app/src/debug/` and `app/src/release/` (not committed; injected + by CI secrets) +- Versioning is driven by `gradle/versioning.gradle` (git-based version codes) diff --git a/app/build.gradle b/app/build.gradle index 4e4e1600..4423cd97 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,57 +1,66 @@ -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' +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.aboutLibraries) + id "com.google.gms.google-services" + id "com.google.firebase.crashlytics" + id 'com.google.dagger.hilt.android' + id 'com.github.triplet.play' version '4.0.0' + id 'com.google.devtools.ksp' + id 'jacoco' +} android { - compileSdkVersion Config.compile_sdk - buildToolsVersion Config.build_tools + namespace "de.psdev.devdrawer" + compileSdk = 36 defaultConfig { applicationId "de.psdev.devdrawer" - minSdkVersion Config.min_sdk - targetSdkVersion Config.target_sdk + minSdkVersion 26 + targetSdkVersion 36 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 + viewBinding true // Required by ChooseActivityDialog (pending Compose migration) + 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" - ] + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } } testOptions { + managedDevices { + localDevices { + pixel8api34 { + device = "Pixel 8 Pro" + apiLevel = 34 + systemImageSource = "google" + require64Bit = true + } + } + } unitTests { includeAndroidResources = true - all { ignoreFailures = true } } } final def keystorePropertiesFile = rootProject.file("release.properties") @@ -84,7 +93,7 @@ android { } release { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' if (keystorePropertiesFile.exists()) { signingConfig signingConfigs.release } else { @@ -92,112 +101,151 @@ android { } } } - lintOptions { - lintConfig project.file('lint.xml') - disable "GoogleAppIndexingWarning" - disable "RemoveWorkManagerInitializer" + packagingOptions { + resources { + excludes += ['**/LICENSE', '**/LICENSE.txt', '**/NOTICE', '**/NOTICE.txt', '**/*.gwt.xml'] + } + } + lint { + disable 'GoogleAppIndexingWarning', 'RemoveWorkManagerInitializer' enable 'Interoperability' + lintConfig file('lint.xml') } - packagingOptions { - exclude '**/LICENSE' - exclude '**/LICENSE.txt' - exclude '**/NOTICE' - exclude '**/NOTICE.txt' - exclude '**/*.gwt.xml' +} + +composeCompiler { + enableStrongSkippingMode = true +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) } } +ksp { + arg("room.schemaLocation", rootProject.file('schemas').toString()) +} + dependencies { // // Platforms // - implementation platform(Platforms.firebase) - implementation platform(Platforms.kotlin) - + def firebaseBom = platform(libs.firebase.bom) + implementation(firebaseBom) + androidTestImplementation(firebaseBom) // // Test dependencies // - testImplementation Libs.junit - testImplementation Libs.robolectric - testImplementation Libs.mockk + testImplementation libs.junit + testImplementation libs.robolectric + testImplementation libs.mockk.android + testImplementation libs.mockk.agent + testImplementation libs.kotlinx.coroutines.test // // Runtime dependencies // // AboutLibraries - implementation Libs.about_libraries + implementation(libs.aboutlibraries.core) + implementation(libs.aboutlibraries.compose.m3) // 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 + implementation libs.androidx.appcompat + implementation libs.androidx.browser + implementation libs.androidx.core.ktx - // Color Picker - implementation "com.github.dhaval2404:colorpicker:2.0" + implementation libs.androidx.core.splashscreen - // Dagger - implementation Libs.daggerHiltAndroid - kapt Libs.daggerHiltAndroidCompiler + implementation libs.hilt.android + ksp libs.hilt.compiler + implementation libs.androidx.hilt.navigation.compose + implementation libs.androidx.hilt.work + ksp libs.androidx.hilt.compiler - // 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 libs.androidx.lifecycle.viewmodel.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 + + // Navigation 3 + implementation libs.androidx.navigation3.runtime + implementation libs.androidx.navigation3.ui + implementation libs.androidx.lifecycle.viewmodel.navigation3 + + implementation libs.androidx.preference.ktx + implementation libs.androidx.room.runtime + ksp libs.androidx.room.compiler + implementation libs.androidx.room.ktx - // FlowBinding - implementation Libs.flowBindingAndroid - implementation Libs.flowBindingCommon - implementation Libs.flowBindingMaterial + implementation libs.androidx.work.runtime.ktx + androidTestImplementation libs.androidx.work.testing + + // Compose + def composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + // Material Design 3 + implementation libs.androidx.material3 + // Android Studio Preview support + implementation libs.androidx.ui.tooling.preview + debugImplementation libs.androidx.ui.tooling + // UI Tests + androidTestImplementation libs.androidx.ui.test.junit4 + debugImplementation libs.androidx.ui.test.manifest + // Optional - Add full set of material icons + implementation libs.androidx.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 libs.colorpicker + + // Firebase + implementation libs.firebase.analytics + implementation libs.firebase.config + implementation libs.firebase.crashlytics + implementation libs.firebase.perf // Google Play - implementation Libs.googlePlayCore - implementation Libs.googlePlayCoreKtx + implementation libs.review + implementation libs.review.ktx + implementation libs.app.update + implementation libs.app.update.ktx // Kotlin - implementation Libs.kotlinStdlib + implementation libs.kotlin.stdlib + implementation libs.kotlinx.serialization.json // Kotlin Coroutines - implementation Libs.kotlinCoroutinesAndroid + implementation libs.kotlinx.coroutines.android // LeakCanary - debugImplementation Libs.leakCanary - implementation Libs.leakCanaryPlumberAndroid +// debugImplementation Libs.leakCanary +// implementation Libs.leakCanaryPlumberAndroid // Logging - implementation Libs.slf4jAndroidLogger - implementation Libs.kotlinLogging + implementation libs.slf4j.android.logger + implementation libs.kotlin.logging // OkHttp - implementation Libs.okhttp + implementation(libs.okhttp) // Okio - implementation Libs.okio + implementation(libs.okio) } -kapt { - correctErrorTypes true +jacoco { + toolVersion = "0.8.12" } play { @@ -208,4 +256,4 @@ play { enabled.set(false) } releaseName.set(project.version) -} \ No newline at end of file +} 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..9863e1a7 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,23 +12,31 @@ - + + + + @@ -46,13 +53,9 @@ android:taskAffinity="" android:theme="@style/AppTheme.Dialog.NoActionBar" /> - - - - - - - + @@ -65,6 +68,10 @@ android:name=".receivers.UpdateReceiver" android:exported="false" /> + + @@ -76,7 +83,7 @@ android:exported="false" tools:node="merge"> diff --git a/app/src/main/java/de/psdev/devdrawer/BaseActivity.kt b/app/src/main/java/de/psdev/devdrawer/BaseActivity.kt index 17b234cf..5493b467 100644 --- a/app/src/main/java/de/psdev/devdrawer/BaseActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/BaseActivity.kt @@ -5,6 +5,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import de.psdev.devdrawer.analytics.TrackingService import de.psdev.devdrawer.review.ReviewManager +import de.psdev.devdrawer.updates.UpdateManager import kotlinx.coroutines.launch import javax.inject.Inject @@ -15,10 +16,19 @@ abstract class BaseActivity : AppCompatActivity() { @Inject lateinit var reviewManager: ReviewManager + @Inject + lateinit var updateManager: UpdateManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { reviewManager.triggerReview(this@BaseActivity) } + updateManager.checkForUpdates(this) + } + + override fun onResume() { + super.onResume() + updateManager.resumeUpdate(this) } -} \ No newline at end of file +} diff --git a/app/src/main/java/de/psdev/devdrawer/BaseFragment.kt b/app/src/main/java/de/psdev/devdrawer/BaseFragment.kt deleted file mode 100644 index dee9748a..00000000 --- a/app/src/main/java/de/psdev/devdrawer/BaseFragment.kt +++ /dev/null @@ -1,59 +0,0 @@ -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() { - - @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 - } - - protected fun updateToolbarTitle(@StringRes resId: Int) { - requireActivity().setTitle(resId) - trackingService.trackScreen(this::class.java, getString(resId)) - } - - val Fragment.viewLifecycleScope: LifecycleCoroutineScope - get() = viewLifecycleOwner.lifecycleScope - -} diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt new file mode 100644 index 00000000..e10ace2b --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt @@ -0,0 +1,242 @@ +package de.psdev.devdrawer + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.annotation.StringRes +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.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.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import de.psdev.devdrawer.analytics.TrackingService +import de.psdev.devdrawer.settings.SettingsViewModel +import de.psdev.devdrawer.settings.ThemeSetting +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import mu.KotlinLogging + +private val logger = KotlinLogging.logger { } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DevDrawerApp( + viewModel: SettingsViewModel = hiltViewModel(), + navigationState: NavigationState, + navigator: Navigator, + trackingService: TrackingService +) { + val settings by viewModel.persistedSettings.collectAsState() + val darkTheme = when (settings.themeSetting) { + ThemeSetting.SYSTEM -> isSystemInDarkTheme() + ThemeSetting.LIGHT -> false + ThemeSetting.DARK -> true + } + + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + DevDrawerTheme( + darkTheme = darkTheme, + dynamicColor = settings.dynamicColorEnabled + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val currentRoute = + navigationState.backStacks[navigationState.topLevelRoute]?.last() ?: navigationState.topLevelRoute + + val (menuComposable, setMenu) = remember { + mutableStateOf(null) + } + + // Reset state on navigation change + LaunchedEffect(currentRoute) { + setMenu(null) + } + + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val navigationIcon: @Composable () -> Unit = + if (currentRoute !in topLevelRoutes) { + { + IconButton(onClick = { backDispatcher?.onBackPressed() }) { + Icon(imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + } + } else { + {} + } + + BackHandler(enabled = currentRoute !in topLevelRoutes) { + navigator.goBack() + } + + val needsOptIn by trackingService.needsOptIn.collectAsState() + if (needsOptIn) { + AnalyticsOptInDialog( + onOptIn = { + trackingService.optIn() + scope.launch { + snackbarHostState.showSnackbar( + message = "Thank you! You can change your decision anytime on the settings tab.", + actionLabel = "OK", + duration = SnackbarDuration.Long + ) + } + }, + onOptOut = { + trackingService.optOut() + } + ) + } + + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary + ), + navigationIcon = navigationIcon, + title = { + Text(text = stringResource(id = currentRoute.title)) + }, + actions = { + menuComposable?.invoke(this) + } + ) + }, + content = { innerPadding -> + DevDrawerHost( + navigationState = navigationState, + navigator = navigator, + menuCallback = setMenu, + modifier = Modifier.padding(innerPadding) + ) + }, + bottomBar = { + NavigationBar { + BottomBarDestination.entries.forEach { destination -> + NavigationBarItem( + selected = navigationState.topLevelRoute == destination.route, + icon = { + Icon( + imageVector = destination.icon, + contentDescription = null + ) + }, + label = { Text(stringResource(destination.label)) }, + onClick = { + navigator.navigate(destination.route) + } + ) + } + } + } + ) + } + } +} + +@Composable +fun AnalyticsOptInDialog( + onOptIn: () -> Unit, + onOptOut: () -> Unit +) { + // Persist the dialog-show timestamp across config changes so rotation doesn't restart the delay. + val shownAt by rememberSaveable { mutableLongStateOf(System.currentTimeMillis()) } + var buttonsEnabled by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (!buttonsEnabled) { + val remaining = 2500L - (System.currentTimeMillis() - shownAt) + if (remaining > 0) delay(remaining) + buttonsEnabled = true + } + } + + AlertDialog( + onDismissRequest = { /* Not cancelable */ }, + title = { + Text(text = "Usage analytics", fontWeight = FontWeight.Bold) + }, + text = { + Text( + text = "In order for us to be able to better understand your use of the app we would like to analyse your usage.\n\n" + + "We use Firebase Analytics to track opened screens and certain interactions.\n\n" + + "We don't store personally identifiable data.\n\n" + + "Additionally we use Firebase Crashlytics for app crashes.\n\n" + + "Thank you for considering!" + ) + }, + confirmButton = { + TextButton( + enabled = buttonsEnabled, + onClick = onOptIn + ) { + Text("Opt-in") + } + }, + dismissButton = { + TextButton( + enabled = buttonsEnabled, + onClick = onOptOut + ) { + Text("Opt-out") + } + } + ) +} + +val topLevelRoutes = listOf( + WidgetListRoute, + WidgetProfilesRoute, + SettingsRoute +) + +enum class BottomBarDestination( + val route: androidx.navigation3.runtime.NavKey, + val icon: ImageVector, + @param:StringRes val label: Int +) { + Widgets(WidgetListRoute, Icons.Default.Widgets, R.string.widgets), + Profiles(WidgetProfilesRoute, Icons.Default.Grid3x3, R.string.profiles), + Settings(SettingsRoute, Icons.Default.Settings, R.string.settings) +} diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt index 06ce493d..cf22bd4c 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt @@ -6,17 +6,17 @@ import android.content.IntentFilter import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import de.psdev.devdrawer.appwidget.CleanupWidgetsWorker import de.psdev.devdrawer.appwidget.UpdateWidgetsWorker import de.psdev.devdrawer.receivers.AppInstallationReceiver -import de.psdev.devdrawer.widgets.CleanupWidgetsWorker import mu.KLogging import javax.inject.Inject import kotlin.system.measureTimeMillis @HiltAndroidApp -class DevDrawerApplication: Application(), Configuration.Provider { +class DevDrawerApplication : Application(), Configuration.Provider { - companion object: KLogging(); + companion object : KLogging(); @Inject lateinit var workerFactory: HiltWorkerFactory @@ -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,10 @@ class DevDrawerApplication: Application(), Configuration.Provider { // Configuration.Provider // ========================================================================================================================== - override fun getWorkManagerConfiguration(): Configuration { - logger.warn { "getWorkManagerConfiguration" } - return Configuration.Builder() + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() .setWorkerFactory(workerFactory) .build() - } // ========================================================================================================================== // Private API diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt new file mode 100644 index 00000000..071c8301 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt @@ -0,0 +1,70 @@ +package de.psdev.devdrawer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import de.psdev.devdrawer.about.AboutScreen +import de.psdev.devdrawer.profiles.ui.editor.WidgetProfileEditor +import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesScreen +import de.psdev.devdrawer.settings.SettingsScreen +import de.psdev.devdrawer.widgets.ui.editor.WidgetEditorScreen +import de.psdev.devdrawer.widgets.ui.list.WidgetListScreen + +@Composable +fun DevDrawerHost( + navigationState: NavigationState, + navigator: Navigator, + menuCallback: AppBarActionsProvider, + modifier: Modifier = Modifier +) { + val entryProvider = entryProvider { + entry { + WidgetListScreen( + onWidgetClick = { widget -> + navigator.navigate(WidgetEditorRoute(widget.id)) + } + ) + } + entry { + WidgetProfilesScreen( + onEditProfile = { profile -> + navigator.navigate(WidgetProfileEditorRoute(profile.id)) + } + ) + } + entry { + SettingsScreen( + onAboutClick = { + navigator.navigate(AboutRoute) + } + ) + } + entry { + AboutScreen() + } + entry { key -> + WidgetEditorScreen( + id = key.id, + menuCallback = menuCallback, + onBack = { navigator.goBack() }, + onEditWidgetProfile = { profile -> + navigator.navigate(WidgetProfileEditorRoute(profile.id)) + } + ) + } + entry { key -> + WidgetProfileEditor( + profileId = key.id, + menuCallback = menuCallback, + onBack = { navigator.goBack() } + ) + } + } + + NavDisplay( + entries = navigationState.toEntries(entryProvider), + onBack = { navigator.goBack() }, + modifier = modifier + ) +} diff --git a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt index 5b118f56..53ee7aa6 100644 --- a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt @@ -1,25 +1,27 @@ package de.psdev.devdrawer +import android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID +import android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID +import android.content.Intent import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.navigation.findNavController -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupWithNavController +import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.databinding.ActivityMainBinding +import kotlinx.coroutines.launch import mu.KLogging -import javax.inject.Inject @AndroidEntryPoint class MainActivity : BaseActivity() { - companion object : KLogging() - private lateinit var binding: ActivityMainBinding - - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase + // Holds intents delivered via onNewIntent so they can be handled inside the Compose tree. + private val newIntent = mutableStateOf(null) // ========================================================================================================================== // Android Lifecycle @@ -27,24 +29,53 @@ 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) - - lifecycleScope.launchWhenResumed { - trackingService.checkOptIn(this@MainActivity) + enableEdgeToEdge() + setContent { + val navigationState = rememberNavigationState( + startRoute = WidgetListRoute, + topLevelRoutes = topLevelRoutes.toSet() + ) + val navigator = remember { Navigator(navigationState) } + + // Handle the launch intent only on a fresh start, not on config changes. + if (savedInstanceState == null) { + LaunchedEffect(Unit) { + handleIntent(intent, navigator) + } + } + + // Handle intents delivered while the app is already running. + val pendingIntent = newIntent.value + LaunchedEffect(pendingIntent) { + pendingIntent?.let { + handleIntent(it, navigator) + newIntent.value = null + } + } + + DevDrawerApp( + navigationState = navigationState, + navigator = navigator, + trackingService = trackingService + ) + } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + trackingService.checkOptIn() + } } } -} + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + newIntent.value = intent + } + private fun handleIntent(intent: Intent, navigator: Navigator) { + val widgetId = intent.getIntExtra(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) + if (widgetId != INVALID_APPWIDGET_ID) { + navigator.navigate(WidgetEditorRoute(widgetId)) + } + } +} 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..ff596dca --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/Navigation.kt @@ -0,0 +1,16 @@ +package de.psdev.devdrawer + +import androidx.annotation.StringRes +import androidx.navigation3.runtime.NavKey + +@get:StringRes +val NavKey.title + get(): Int = when (this) { + is SettingsRoute -> R.string.settings + is WidgetListRoute -> R.string.widgets + is WidgetProfilesRoute -> R.string.profiles + is WidgetEditorRoute -> R.string.edit_widget + is WidgetProfileEditorRoute -> R.string.edit_profile + is AboutRoute -> R.string.app_info + else -> R.string.app_name + } diff --git a/app/src/main/java/de/psdev/devdrawer/NavigationState.kt b/app/src/main/java/de/psdev/devdrawer/NavigationState.kt new file mode 100644 index 00000000..d9ea6d2b --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/NavigationState.kt @@ -0,0 +1,91 @@ +package de.psdev.devdrawer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.runtime.serialization.NavKeySerializer +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer + +/** + * Create a navigation state that persists config changes and process death. + */ +@Composable +fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: Set +): NavigationState { + + val topLevelRoute = rememberSerializable( + startRoute, topLevelRoutes, + serializer = MutableStateSerializer(NavKeySerializer()) + ) { + mutableStateOf(startRoute) + } + + val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } + + return remember(startRoute, topLevelRoutes) { + NavigationState( + startRoute = startRoute, + topLevelRoute = topLevelRoute, + backStacks = backStacks + ) + } +} + +/** + * State holder for navigation state. + * + * @param startRoute - the start route. The user will exit the app through this route. + * @param topLevelRoute - the current top level route + * @param backStacks - the back stacks for each top level route + */ +class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map> +) { + var topLevelRoute: NavKey by topLevelRoute + val stacksInUse: List + get() = if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } +} + +/** + * Convert NavigationState into NavEntries. + */ +@Composable +fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry +): SnapshotStateList> { + + val decoratedEntries = backStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + return stacksInUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() +} diff --git a/app/src/main/java/de/psdev/devdrawer/Navigator.kt b/app/src/main/java/de/psdev/devdrawer/Navigator.kt new file mode 100644 index 00000000..14d01112 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/Navigator.kt @@ -0,0 +1,29 @@ +package de.psdev.devdrawer + +import androidx.navigation3.runtime.NavKey + +/** + * Handles navigation events (forward and back) by updating the navigation state. + */ +class Navigator(val state: NavigationState) { + fun navigate(route: NavKey) { + if (route in state.backStacks.keys) { + // This is a top level route, just switch to it. + state.topLevelRoute = route + } else { + state.backStacks[state.topLevelRoute]?.add(route) + } + } + + fun goBack() { + val currentStack = state.backStacks[state.topLevelRoute] ?: error("Stack for ${state.topLevelRoute} not found") + val currentRoute = currentStack.last() + + // If we're at the base of the current route, go back to the start route stack. + if (currentRoute == state.topLevelRoute) { + state.topLevelRoute = state.startRoute + } else { + currentStack.removeLastOrNull() + } + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/Routes.kt b/app/src/main/java/de/psdev/devdrawer/Routes.kt new file mode 100644 index 00000000..fddd9249 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/Routes.kt @@ -0,0 +1,22 @@ +package de.psdev.devdrawer + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object WidgetListRoute : NavKey + +@Serializable +data object WidgetProfilesRoute : NavKey + +@Serializable +data object SettingsRoute : NavKey + +@Serializable +data object AboutRoute : NavKey + +@Serializable +data class WidgetEditorRoute(val id: Int) : NavKey + +@Serializable +data class WidgetProfileEditorRoute(val id: String) : NavKey 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..eeea2451 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/TopBarMenu.kt @@ -0,0 +1,19 @@ +package de.psdev.devdrawer + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +typealias AppBarActions = @Composable RowScope.() -> Unit +typealias AppBarActionsProvider = (AppBarActions?) -> Unit + +@Composable +fun ProvideMenu( + actionsProvider: AppBarActionsProvider, + updateKey: Any? = null, + actions: AppBarActions +) { + LaunchedEffect(key1 = updateKey) { + actionsProvider(actions) + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt b/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt deleted file mode 100644 index fa33fa0b..00000000 --- a/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt +++ /dev/null @@ -1,72 +0,0 @@ -package de.psdev.devdrawer.about - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -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 dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.BaseFragment -import de.psdev.devdrawer.R -import de.psdev.devdrawer.databinding.FragmentAboutBinding -import de.psdev.devdrawer.utils.consume - -@AndroidEntryPoint -class AboutFragment : BaseFragment() { - - override fun createViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentAboutBinding = - FragmentAboutBinding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - 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) - } - 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) - } - }) - .supportFragment() - - add(R.id.container_fragment, fragment) - } - } - } - - override fun onResume() { - super.onResume() - updateToolbarTitle(R.string.app_info) - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/about/AboutScreen.kt b/app/src/main/java/de/psdev/devdrawer/about/AboutScreen.kt new file mode 100644 index 00000000..a9c543f9 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/about/AboutScreen.kt @@ -0,0 +1,99 @@ +package de.psdev.devdrawer.about + +import android.content.Intent +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +import de.psdev.devdrawer.BuildConfig +import de.psdev.devdrawer.R + +@Composable +fun AboutScreen( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val libraries by produceLibraries(R.raw.aboutlibraries) + LibrariesContainer( + libraries = libraries, + modifier = modifier.fillMaxSize(), + header = { + item { + AboutHeader() + } + }, + ) +} + +@Composable +private fun AboutHeader() { + val context = LocalContext.current + val appIcon = remember(context) { + context.packageManager.getApplicationIcon(context.packageName).toBitmap().asImageBitmap() + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + bitmap = appIcon, + contentDescription = null, + modifier = Modifier.size(96.dp) + ) + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.headlineMedium + ) + Text( + text = "Version ${BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.bodyLarge + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + Button(onClick = { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = "https://play.google.com/store/apps/details?id=de.psdev.devdrawer".toUri() + setPackage("com.android.vending") + } + context.startActivity(intent) + }) { + Text("Play Store") + } + OutlinedButton(onClick = { + val customTabsIntent = CustomTabsIntent.Builder().build() + customTabsIntent.intent.data = "https://github.com/PSDev/DevDrawer".toUri() + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(customTabsIntent.intent) + }) { + Text("GitHub") + } + } + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/adapters/BaseListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/adapters/BaseListAdapter.kt deleted file mode 100644 index 7cb6acfd..00000000 --- a/app/src/main/java/de/psdev/devdrawer/adapters/BaseListAdapter.kt +++ /dev/null @@ -1,79 +0,0 @@ -package de.psdev.devdrawer.adapters - -import android.database.DataSetObservable -import android.database.DataSetObserver -import android.view.View -import android.view.ViewGroup -import android.widget.ListAdapter - -abstract class BaseListAdapter: ListAdapter { - private val dataSetObservable = DataSetObservable() - - private val items = mutableListOf() - - // ========================================================================================================================== - // BaseAdapter - // ========================================================================================================================== - - @Suppress("UNCHECKED_CAST") - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View? { - val view = convertView?.tag as? VH ?: onCreateViewHolder(parent, getItemViewType(position)) - onBindViewHolder(view, position) - return view.itemView - } - - override fun getItem(position: Int): T = items[position] - override fun getItemId(position: Int): Long = position.toLong() - override fun getCount(): Int = items.size - override fun isEmpty(): Boolean = items.isEmpty() - override fun getItemViewType(position: Int): Int = 0 - override fun getViewTypeCount(): Int = 1 - override fun isEnabled(position: Int): Boolean = true - override fun hasStableIds(): Boolean = false - override fun areAllItemsEnabled(): Boolean = true - - override fun registerDataSetObserver(observer: DataSetObserver) { - dataSetObservable.registerObserver(observer) - } - - override fun unregisterDataSetObserver(observer: DataSetObserver) { - dataSetObservable.unregisterObserver(observer) - } - - /** - * Notifies the attached observers that the underlying data has been changed - * and any View reflecting the data set should refresh itself. - */ - fun notifyDataSetChanged() { - dataSetObservable.notifyChanged() - } - - /** - * Notifies the attached observers that the underlying data is no longer valid - * or available. Once invoked this adapter is no longer valid and should - * not report further data set changes. - */ - fun notifyDataSetInvalidated() { - dataSetObservable.notifyInvalidated() - } - - // ========================================================================================================================== - // Public API - // ========================================================================================================================== - - fun update(data: List) { - items.clear() - items.addAll(data) - notifyDataSetChanged() - } - - // ========================================================================================================================== - // Internal API - // ========================================================================================================================== - - protected abstract fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH - protected abstract fun onBindViewHolder(holder: VH, position: Int) - - abstract class ViewHolder(internal val itemView: View) - -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/adapters/PartialMatchAdapter.kt b/app/src/main/java/de/psdev/devdrawer/adapters/PartialMatchAdapter.kt deleted file mode 100644 index e40155f0..00000000 --- a/app/src/main/java/de/psdev/devdrawer/adapters/PartialMatchAdapter.kt +++ /dev/null @@ -1,103 +0,0 @@ -package de.psdev.devdrawer.adapters - -import android.app.Activity -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import android.widget.Filter -import android.widget.Filterable -import android.widget.TextView -import de.psdev.devdrawer.R -import de.psdev.devdrawer.database.DevDrawerDatabase -import kotlinx.coroutines.runBlocking - -class PartialMatchAdapter( - activity: Activity, - private val profileId: String, - private val items: List, - private val devDrawerDatabase: DevDrawerDatabase, - private val editMode: Boolean = false -) : BaseAdapter(), Filterable { - private val filteredItems = mutableListOf() - private val layoutInflater: LayoutInflater = activity.layoutInflater - private val packageFilter = object : Filter() { - override fun performFiltering(charSequence: CharSequence?): FilterResults { - return if (charSequence == null) { - FilterResults().apply { - count = items.size - values = items - } - } else { - val existingFilters = - runBlocking { devDrawerDatabase.packageFilterDao().findAllByProfile(profileId) } - .map { it.filter } - val existingFilterRegexes = existingFilters - .map { it.replace("*", ".*").toRegex() } - val filteredItems = items - // Filter items already added - .filterNot { !editMode && existingFilters.contains(it) } - // Filter item matching existing filters with regex - .filterNot { !editMode && existingFilterRegexes.any { regex -> regex.matches(it) } } - // Filter matching - .filter { it.lowercase().contains(charSequence.toString().lowercase()) } - FilterResults().apply { - count = filteredItems.size - values = filteredItems - } - } - } - - @Suppress("UNCHECKED_CAST") - override fun publishResults(charSequence: CharSequence?, filterResults: FilterResults) { - filteredItems.clear() - filteredItems.addAll(filterResults.values as Collection) - notifyDataSetChanged() - } - } - - // ========================================================================================================================== - // BaseAdapter - // ========================================================================================================================== - - override fun getCount(): Int = filteredItems.size - - override fun getItem(position: Int): Any = filteredItems[position] - - override fun getItemId(position: Int): Long = filteredItems[position].hashCode().toLong() - - override fun getView(position: Int, convertView: View?, viewGroup: ViewGroup): View { - val view = convertView ?: createView(viewGroup) - bindView(position, view) - return view - } - - // ========================================================================================================================== - // Filterable - // ========================================================================================================================== - - override fun getFilter(): Filter = packageFilter - - // ========================================================================================================================== - // Private API - // ========================================================================================================================== - - private fun createView(parent: ViewGroup): View { - return layoutInflater.inflate(R.layout.dropdown_list_item, parent, false).apply { - tag = ViewHolder(this) - } - } - - private fun bindView(position: Int, view: View) { - val holder: ViewHolder = view.tag as ViewHolder - holder.textView.text = filteredItems[position] - } - - // ========================================================================================================================== - // Inner classes - // ========================================================================================================================== - - class ViewHolder(view: View) { - var textView: TextView = view.findViewById(android.R.id.text1) - } -} diff --git a/app/src/main/java/de/psdev/devdrawer/analytics/Events.kt b/app/src/main/java/de/psdev/devdrawer/analytics/Events.kt index c0cdc81a..c3b663f2 100644 --- a/app/src/main/java/de/psdev/devdrawer/analytics/Events.kt +++ b/app/src/main/java/de/psdev/devdrawer/analytics/Events.kt @@ -2,4 +2,7 @@ package de.psdev.devdrawer.analytics object Events { const val EVENT_WIDGET_OPEN_SETTINGS = "widget_open_settings" -} \ No newline at end of file + const val WIDGET_DELETED = "widget_deleted" + const val PROFILE_CREATED = "widget_profile_created" + const val PROFILE_DELETED = "widget_profile_deleted" +} 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..db725fa7 100644 --- a/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt +++ b/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt @@ -1,36 +1,26 @@ package de.psdev.devdrawer.analytics -import android.app.Activity import android.app.Application import android.content.SharedPreferences.OnSharedPreferenceChangeListener -import android.text.method.LinkMovementMethod -import android.widget.TextView -import androidx.appcompat.app.AlertDialog import androidx.core.content.edit -import androidx.core.text.bold -import androidx.core.text.buildSpannedString -import androidx.core.text.italic -import androidx.core.text.parseAsHtml import androidx.preference.PreferenceManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar +import com.google.firebase.Firebase import com.google.firebase.analytics.FirebaseAnalytics -import com.google.firebase.analytics.ktx.analytics -import com.google.firebase.analytics.ktx.logEvent -import com.google.firebase.ktx.Firebase +import com.google.firebase.analytics.analytics +import com.google.firebase.analytics.logEvent import de.psdev.devdrawer.config.RemoteConfigService -import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import mu.KLogging import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.resume @Singleton 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" @@ -38,23 +28,14 @@ class TrackingService @Inject constructor( } private val sharedPreferences by lazy { - PreferenceManager.getDefaultSharedPreferences( - application - ) + PreferenceManager.getDefaultSharedPreferences(application) } - private var firebaseAnalyticsOptInStatus: OptInStatus - get() = if (sharedPreferences.contains(PREF_KEY_OPTED_IN)) { - when (sharedPreferences.getBoolean(PREF_KEY_OPTED_IN, false)) { - true -> OptInStatus.OPT_IN - false -> OptInStatus.OPT_OUT - } - } else OptInStatus.UNKNOWN - set(value) = when (value) { - OptInStatus.OPT_IN -> sharedPreferences.edit { putBoolean(PREF_KEY_OPTED_IN, true) } - OptInStatus.OPT_OUT -> sharedPreferences.edit { putBoolean(PREF_KEY_OPTED_IN, false) } - OptInStatus.UNKNOWN -> throw IllegalArgumentException("UNKNOWN not allowed") - } + + private val _needsOptIn = MutableStateFlow(false) + val needsOptIn: StateFlow = _needsOptIn + private val firebaseAnalytics: FirebaseAnalytics = Firebase.analytics + private val preferenceChangedListener = OnSharedPreferenceChangeListener { sharedPreferences, key -> logger.info { "Preference changed: $key" } @@ -64,26 +45,19 @@ class TrackingService @Inject constructor( sharedPreferences.edit { putLong(PREF_KEY_OPTED_IN_TIME, System.currentTimeMillis()) } - if (value) { - setConsentStatus(FirebaseAnalytics.ConsentStatus.GRANTED) - firebaseAnalytics.setAnalyticsCollectionEnabled(true) - } else { - setConsentStatus(FirebaseAnalytics.ConsentStatus.DENIED) - firebaseAnalytics.setAnalyticsCollectionEnabled(false) - } + updateAnalyticsCollection(value) + _needsOptIn.value = false } } } - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) init { sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangedListener) + updateAnalyticsCollection(sharedPreferences.getBoolean(PREF_KEY_OPTED_IN, false)) } fun trackAction(name: String) { - firebaseAnalytics.logEvent(name) { - - } + firebaseAnalytics.logEvent(name) {} } fun trackScreen(clazz: Class<*>, name: String) { @@ -93,98 +67,40 @@ class TrackingService @Inject constructor( } } - suspend fun checkOptIn(activity: Activity) { + suspend fun checkOptIn() { if (remoteConfigService.getBoolean(CONFIG_KEY_ENABLED)) { val optInTime = sharedPreferences.getLong(PREF_KEY_OPTED_IN_TIME, 0L) val minOptInTime = remoteConfigService.getLong(CONFIG_KEY_MIN_TIME) val optInTooOld = optInTime < minOptInTime - val status = firebaseAnalyticsOptInStatus + + val status = if (sharedPreferences.contains(PREF_KEY_OPTED_IN)) { + if (sharedPreferences.getBoolean(PREF_KEY_OPTED_IN, false)) OptInStatus.OPT_IN else OptInStatus.OPT_OUT + } else OptInStatus.UNKNOWN + if (status == OptInStatus.UNKNOWN || (status == OptInStatus.OPT_IN && optInTooOld)) { - suspendCancellableCoroutine { continuation -> - val alertDialog = MaterialAlertDialogBuilder(activity) - .setTitle(buildSpannedString { bold { appendLine("Usage analytics") } }) - .setMessage( - // If optInTooOld use different message - buildSpannedString { - italic { appendLine("Thank you for installing this app.") }.appendLine() - appendLine("In order for us to be able to better understand your use of the app we would like to analyse your usage.").appendLine() - bold { appendLine("What does this mean exactly?") } - appendLine("We use Firebase Analytics to track") - appendLine("* opened screens") - appendLine("* certain interactions (clicks) on elements").appendLine() - appendLine("We don't store any personally identifiably data and don't collect your advertising ID.").appendLine() - appendLine("Additionally we use Firebase Crashlytics to get automatic reports of app crashes.").appendLine() - append("If you have any question you can write us at ") - append("privacy@psdev.de".parseAsHtml()) - appendLine(".") - appendLine("Please consider helping us by opting in.").appendLine() - bold { append("Thank you!") } - } - ) - .setPositiveButton("Opt-in") { _, _ -> - optIn() - Snackbar.make( - activity.findViewById(android.R.id.content), - buildSpannedString { - bold { append("Thank you! You can change your decision anytime on the settings tab.") } - }, - Snackbar.LENGTH_LONG - ).apply { - animationMode = Snackbar.ANIMATION_MODE_SLIDE - setAction("OK") { - dismiss() - } - }.show() - } - .setNegativeButton("Opt-out") { _, _ -> optOut() } - .setOnDismissListener { continuation.resume(Unit) } - .setCancelable(false) - .create() - alertDialog.setCanceledOnTouchOutside(false) - val job = coroutineScope.async(start = CoroutineStart.LAZY) { - alertDialog.findViewById(android.R.id.message)?.movementMethod = - LinkMovementMethod.getInstance() - val positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) - val negativeButton = alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE) - positiveButton.isEnabled = false - negativeButton.isEnabled = false - delay(2500) - positiveButton.isEnabled = true - negativeButton.isEnabled = true - } - continuation.invokeOnCancellation { - alertDialog.dismiss() - job.cancel() - } - alertDialog.setOnShowListener { job.start() } - alertDialog.show() - } - } else if (status == OptInStatus.OPT_IN) { - firebaseAnalytics.setAnalyticsCollectionEnabled(true) + _needsOptIn.value = true } } else { - firebaseAnalytics.setAnalyticsCollectionEnabled(false) + // Remote config has disabled analytics — ensure collection is off regardless of stored preference. + updateAnalyticsCollection(false) } } - private fun optIn() { - firebaseAnalyticsOptInStatus = OptInStatus.OPT_IN + fun optIn() { + sharedPreferences.edit { putBoolean(PREF_KEY_OPTED_IN, true) } } - private fun optOut() { - firebaseAnalyticsOptInStatus = OptInStatus.OPT_OUT + fun optOut() { + sharedPreferences.edit { putBoolean(PREF_KEY_OPTED_IN, false) } } - private fun setConsentStatus(contentStatus: FirebaseAnalytics.ConsentStatus) { - firebaseAnalytics.setConsent( - mapOf( - FirebaseAnalytics.ConsentType.ANALYTICS_STORAGE to contentStatus - ) - ) + private fun updateAnalyticsCollection(enabled: Boolean) { + val consent = if (enabled) FirebaseAnalytics.ConsentStatus.GRANTED else FirebaseAnalytics.ConsentStatus.DENIED + firebaseAnalytics.setConsent(mapOf(FirebaseAnalytics.ConsentType.ANALYTICS_STORAGE to consent)) + firebaseAnalytics.setAnalyticsCollectionEnabled(enabled) } enum class OptInStatus { UNKNOWN, OPT_IN, OPT_OUT } - -} \ No newline at end of file +} diff --git a/app/src/main/java/de/psdev/devdrawer/apps/AppsService.kt b/app/src/main/java/de/psdev/devdrawer/apps/AppsService.kt new file mode 100644 index 00000000..21c4dab7 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/apps/AppsService.kt @@ -0,0 +1,64 @@ +package de.psdev.devdrawer.apps + +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.Firebase +import com.google.firebase.perf.performance +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.appwidget.isSystemApp +import de.psdev.devdrawer.appwidget.toAppInfo +import de.psdev.devdrawer.appwidget.toPackageHashInfo +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 + } + +} 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..223f2297 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt @@ -1,11 +1,10 @@ package de.psdev.devdrawer.appwidget import android.content.Context -import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable -import androidx.recyclerview.widget.DiffUtil +import android.os.Build import mu.KotlinLogging import okio.HashingSink import okio.blackholeSink @@ -17,26 +16,35 @@ data class AppInfo( val name: String, val packageName: String, val appIcon: Drawable, - val firstInstalledTime: Long, + val firstInstallTime: Long, val lastUpdateTime: Long, - val signatureSha256: String -) { - companion object { - val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: AppInfo, newItem: AppInfo): Boolean = - oldItem.packageName == newItem.packageName - - override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo): Boolean = oldItem == newItem - } - } -} + val signatureHashSha256: String, + val versionName: String = "", + val versionCode: Long = 0 +) fun PackageHashInfo.toAppInfo(context: Context): AppInfo? = try { val packageManager = context.packageManager - val applicationInfo: ApplicationInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES).applicationInfo + val packageInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES) + val applicationInfo = packageInfo.applicationInfo!! val appName = applicationInfo.loadLabel(packageManager).toString() val appIcon = applicationInfo.loadIcon(packageManager) - AppInfo(appName, packageName, appIcon, firstInstallTime, lastUpdateTime, signatureHashSha256) + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + AppInfo( + name = appName, + packageName = packageName, + appIcon = appIcon, + firstInstallTime = firstInstallTime, + lastUpdateTime = lastUpdateTime, + signatureHashSha256 = signatureHashSha256, + versionName = packageInfo.versionName ?: "", + versionCode = versionCode + ) } catch (e: Exception) { logger.warn(e) { "Error: ${e.message}" } null @@ -47,7 +55,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/CleanupWidgetsWorker.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/CleanupWidgetsWorker.kt new file mode 100644 index 00000000..94b0bb98 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/CleanupWidgetsWorker.kt @@ -0,0 +1,72 @@ +package de.psdev.devdrawer.appwidget + +import android.app.Application +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import de.psdev.devdrawer.database.DevDrawerDatabase +import mu.KLogging +import java.util.concurrent.TimeUnit + +@HiltWorker +class CleanupWidgetsWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + private val devDrawerDatabase: DevDrawerDatabase +) : CoroutineWorker(context, workerParams) { + companion object : KLogging() { + @JvmField + val TAG: String = CleanupWidgetsWorker::class.java.simpleName + + fun enableWorker(application: Application) { + val workManager = WorkManager.getInstance(application) + + workManager.enqueueUniqueWork( + TAG, + ExistingWorkPolicy.APPEND_OR_REPLACE, + OneTimeWorkRequestBuilder().build() + ) + workManager.enqueueUniquePeriodicWork( + TAG, + ExistingPeriodicWorkPolicy.REPLACE, + PeriodicWorkRequestBuilder( + WorkerConstants.PERIODIC_INTERVAL_MINUTES, + TimeUnit.MINUTES + ).build() + ) + } + } + + override suspend fun doWork(): Result { + logger.warn { "Cleaning orphaned widgets..." } + val widgetDao = devDrawerDatabase.widgetDao() + val widgetManager = AppWidgetManager.getInstance(applicationContext) + + val widgets = widgetDao.findAll() + val databaseWidgetIds = widgets.map { it.id } + val appWidgetIds = widgetManager.getAppWidgetIds( + ComponentName( + applicationContext, + DDWidgetProvider::class.java + ) + ) + + val deletedWidgets = databaseWidgetIds - appWidgetIds.toSet() + + if (deletedWidgets.isNotEmpty()) { + logger.warn { "Deleting orphaned widgets from local database: $deletedWidgets" } + widgetDao.deleteByIds(deletedWidgets) + } + return Result.success() + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/ClickHandlingActivity.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/ClickHandlingActivity.kt index 74e02594..3567330a 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/ClickHandlingActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/ClickHandlingActivity.kt @@ -10,6 +10,7 @@ import android.provider.Settings import androidx.fragment.app.FragmentActivity import dagger.hilt.android.AndroidEntryPoint import de.psdev.devdrawer.R +import de.psdev.devdrawer.settings.PreferenceKeys import de.psdev.devdrawer.utils.Constants import mu.KLogging import javax.inject.Inject @@ -58,7 +59,7 @@ class ClickHandlingActivity : FragmentActivity() { private fun startApp(packageName: String) { if (sharedPreferences.getBoolean( - getString(R.string.pref_show_activity_choice), + PreferenceKeys.SHOW_ACTIVITY_CHOICE, resources.getBoolean(R.bool.pref_show_activity_choice_default) ) ) { 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..1335d456 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt @@ -6,79 +6,55 @@ 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 androidx.core.net.toUri import dagger.hilt.android.AndroidEntryPoint +import de.psdev.devdrawer.MainActivity 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 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 -import java.util.* +import java.util.Date 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() // ========================================================================================================================== // 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) { - for (appWidgetId in appWidgetIds) { - val widget = devDrawerDatabase.widgetDao().findById(appWidgetId) - if (widget != null) { - updateWidget(context, widget, appWidgetManager) - } else { - logger.warn { "Widget $appWidgetId does not exist" } + val pendingResult = goAsync() + CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) { + try { + 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" } + } } + } finally { + pendingResult.finish() } } } @@ -86,21 +62,29 @@ class DDWidgetProvider : AppWidgetProvider() { override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) logger.warn { "Deleted widgets ${appWidgetIds.joinToString()}" } - GlobalScope.launch(Dispatchers.IO) { - devDrawerDatabase.widgetDao().deleteByIds(appWidgetIds.toList()) + val pendingResult = goAsync() + CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) { + try { + devDrawerDatabase.widgetDao().deleteByIds(appWidgetIds.toList()) + } finally { + pendingResult.finish() + } } } + @Suppress("DEPRECATION") // notifyAppWidgetViewDataChanged is deprecated along with the RemoteViewsService pattern 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) - appWidgetManager.notifyAppWidgetViewDataChanged(widget.id, R.id.listView) + appWidgetManager.notifyAppWidgetViewDataChanged(intArrayOf(widget.id), R.id.listView) } catch (e: Exception) { logger.warn(e) { "Error updating widget: ${widget.id}: ${e.message}" } } } + @Suppress("DEPRECATION") // setRemoteAdapter(Int, Intent) requires RemoteCollectionItems to replace — large refactor private fun createRemoteViews(context: Context, widget: Widget): RemoteViews { // Setup the widget, and data source / adapter val widgetView = RemoteViews(context.packageName, R.layout.widget_layout) @@ -119,32 +103,43 @@ 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 configActivityIntent = Intent(context, MainActivity::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget.id) + putExtra("from_widget", true) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK) + } + val configActivityPendingIntent = PendingIntent.getActivity( + context, + 0, + configActivityIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) widgetView.setOnClickPendingIntent(R.id.btn_settings, configActivityPendingIntent) // Apps list val appListServiceIntent = Intent(context, WidgetService::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget.id) putExtra("viewId", R.id.listView) - data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) + data = toUri(Intent.URI_INTENT_SCHEME).toUri() } widgetView.setRemoteAdapter(R.id.listView, appListServiceIntent) 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_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) widgetView.setPendingIntentTemplate(R.id.listView, clickPI) return widgetView } -} \ No newline at end of file +} 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..187d853c 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) != 0 \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/SaveWidgetWorker.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/SaveWidgetWorker.kt new file mode 100644 index 00000000..51b4c598 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/SaveWidgetWorker.kt @@ -0,0 +1,49 @@ +package de.psdev.devdrawer.appwidget + +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() + } +} 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..052b3bb6 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, @@ -21,7 +26,7 @@ class UpdateWidgetsWorker @AssistedInject constructor( fun enableWorker(application: Application) { val workManager = WorkManager.getInstance(application) val request = PeriodicWorkRequestBuilder( - repeatInterval = 30, + repeatInterval = WorkerConstants.PERIODIC_INTERVAL_MINUTES, repeatIntervalTimeUnit = TimeUnit.MINUTES ).build() workManager.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) 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..f355fa1c 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt @@ -11,10 +11,15 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.widget.RemoteViews import android.widget.RemoteViewsService -import androidx.annotation.StringRes import de.psdev.devdrawer.R import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.settings.PreferenceKeys import de.psdev.devdrawer.utils.Constants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import mu.KLogging @@ -36,6 +41,11 @@ class WidgetAppsListViewFactory( private val packageManager: PackageManager by lazy { context.packageManager } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile + private var cachedApps: List = emptyList() + private val apps: MutableList = mutableListOf() // ========================================================================================================================== @@ -44,17 +54,19 @@ class WidgetAppsListViewFactory( override fun onCreate() { logger.warn { "onCreate" } + scope.launch { cachedApps = loadApps() } } override fun onDataSetChanged() { logger.warn { "onDataSetChanged" } - runBlocking { - loadApps() - } + cachedApps = runBlocking(Dispatchers.IO) { loadApps() } + apps.clear() + apps.addAll(cachedApps) } override fun onDestroy() { logger.warn { "onDestroy" } + scope.cancel() } override fun getCount(): Int = apps.size @@ -113,10 +125,9 @@ class WidgetAppsListViewFactory( * Method to get all apps from the app database and add to the dataset */ @Suppress("DEPRECATION") - private suspend fun loadApps() { - val devDrawerDatabase = devDrawerDatabase + private suspend fun loadApps(): List { val widget = - devDrawerDatabase.widgetDao().findById(appWidgetId) ?: throw IllegalStateException("Unknown widget") + devDrawerDatabase.widgetDao().findById(appWidgetId) ?: return emptyList() val packageFilters = devDrawerDatabase.packageFilterDao() .findAllByProfile(widget.profileId) @@ -124,7 +135,7 @@ class WidgetAppsListViewFactory( val installedPackages = packageManager.getInstalledPackages(PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES) .map { it.toPackageHashInfo() } - val appList = installedPackages.asSequence() + return installedPackages.asSequence() .filter { packageInfo -> packageFilters.any { filter -> filter.matches(packageInfo) } } @@ -132,21 +143,15 @@ class WidgetAppsListViewFactory( .sortedWith(appComparator) .distinct() .toList() - - apps.clear() - apps.addAll(appList) } private val appComparator: Comparator get() { val defaultSortOrder = context.getString(R.string.pref_sort_order_default) return when (SortOrder.valueOf( - sharedPreferences.getNonNullString( - R.string.pref_sort_order, - defaultSortOrder - ) + sharedPreferences.getString(PreferenceKeys.SORT_ORDER, defaultSortOrder) ?: 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 } @@ -172,9 +177,4 @@ class WidgetAppsListViewFactory( return bmp } - private fun SharedPreferences.getNonNullString( - @StringRes stringRes: Int, - defaultValue: String - ): String = getString(context.getString(stringRes), defaultValue) ?: defaultValue - } diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/WorkerConstants.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/WorkerConstants.kt new file mode 100644 index 00000000..6686bc25 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/WorkerConstants.kt @@ -0,0 +1,5 @@ +package de.psdev.devdrawer.appwidget + +object WorkerConstants { + const val PERIODIC_INTERVAL_MINUTES = 30L +} diff --git a/app/src/main/java/de/psdev/devdrawer/config/RemoteConfigService.kt b/app/src/main/java/de/psdev/devdrawer/config/RemoteConfigService.kt index 38eeee8f..5dcfca1a 100644 --- a/app/src/main/java/de/psdev/devdrawer/config/RemoteConfigService.kt +++ b/app/src/main/java/de/psdev/devdrawer/config/RemoteConfigService.kt @@ -1,9 +1,9 @@ package de.psdev.devdrawer.config -import com.google.firebase.ktx.Firebase -import com.google.firebase.remoteconfig.ktx.get -import com.google.firebase.remoteconfig.ktx.remoteConfig -import com.google.firebase.remoteconfig.ktx.remoteConfigSettings +import com.google.firebase.Firebase +import com.google.firebase.remoteconfig.get +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings import de.psdev.devdrawer.BuildConfig import de.psdev.devdrawer.R import de.psdev.devdrawer.utils.await diff --git a/app/src/main/java/de/psdev/devdrawer/database/BaseDao.kt b/app/src/main/java/de/psdev/devdrawer/database/BaseDao.kt index becd1eed..fb1b255c 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/BaseDao.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/BaseDao.kt @@ -1,10 +1,6 @@ package de.psdev.devdrawer.database -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Transaction -import androidx.room.Update +import androidx.room.* abstract class BaseDao { diff --git a/app/src/main/java/de/psdev/devdrawer/database/Converters.kt b/app/src/main/java/de/psdev/devdrawer/database/Converters.kt index 894ea48a..8f344557 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/Converters.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/Converters.kt @@ -1,6 +1,7 @@ package de.psdev.devdrawer.database import androidx.room.TypeConverter +import java.time.Instant class Converters { @TypeConverter @@ -8,4 +9,10 @@ class Converters { @TypeConverter fun toFilterType(value: String?): FilterType? = value?.let { FilterType.valueOf(it) } + + @TypeConverter + fun fromOffsetDateTIme(value: Instant?): Long? = value?.toEpochMilli() + + @TypeConverter + fun toOffsetDateTime(value: Long?): Instant? = value?.let { Instant.ofEpochMilli(it) } } diff --git a/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt b/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt index 9cb774b8..4d06a85c 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt @@ -20,6 +20,7 @@ class DatabaseModule { DevDrawerDatabase.NAME ).apply { addMigrations(MigrationFrom1To2(application)) + addMigrations(MigrationFrom2To3) }.build() } \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt b/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt index bdac6da9..64c16385 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt @@ -16,7 +16,7 @@ abstract class DevDrawerDatabase: RoomDatabase() { companion object { const val NAME = "DevDrawer.db" - const val VERSION = 2 + const val VERSION = 3 } abstract fun widgetDao(): WidgetDao diff --git a/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt b/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt index cc6e7e51..4f8160b5 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt @@ -7,36 +7,43 @@ import android.graphics.Color import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import de.psdev.devdrawer.appwidget.DDWidgetProvider -import java.util.* +import java.util.UUID class MigrationFrom1To2( private val application: Application ) : Migration(1, 2) { private val appWidgetManager by lazy { AppWidgetManager.getInstance(application) } - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { // Create profiles tables - database.execSQL("CREATE TABLE IF NOT EXISTS `widget_profiles` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))") + db.execSQL("CREATE TABLE IF NOT EXISTS `widget_profiles` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))") // Insert default profile val defaultProfileId = UUID.randomUUID().toString() - database.execSQL("INSERT INTO `widget_profiles` (`id`, `name`) VALUES ('$defaultProfileId', 'Default')") + db.execSQL("INSERT INTO `widget_profiles` (`id`, `name`) VALUES ('$defaultProfileId', 'Default')") // Migrate filters table - database.execSQL("ALTER TABLE `filters` RENAME TO `filters_old`") - database.execSQL("CREATE TABLE IF NOT EXISTS `filters` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `filter` TEXT NOT NULL, `description` TEXT NOT NULL, `profile_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )") - database.execSQL("INSERT INTO `filters` (`id`, `type`, `filter`, `description`, `profile_id`) SELECT `id`, 'PACKAGE_NAME', `filter`, '', '$defaultProfileId' FROM `filters_old`") - database.execSQL("CREATE INDEX IF NOT EXISTS `index_filters_profile_id` ON `filters` (`profile_id`)") - database.execSQL("DROP TABLE `filters_old`") + db.execSQL("ALTER TABLE `filters` RENAME TO `filters_old`") + db.execSQL("CREATE TABLE IF NOT EXISTS `filters` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `filter` TEXT NOT NULL, `description` TEXT NOT NULL, `profile_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )") + db.execSQL("INSERT INTO `filters` (`id`, `type`, `filter`, `description`, `profile_id`) SELECT `id`, 'PACKAGE_NAME', `filter`, '', '$defaultProfileId' FROM `filters_old`") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_filters_profile_id` ON `filters` (`profile_id`)") + db.execSQL("DROP TABLE `filters_old`") // Create widgets table - database.execSQL("CREATE TABLE IF NOT EXISTS `widgets` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `profile_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )") - database.execSQL("CREATE INDEX IF NOT EXISTS `index_widgets_name` ON `widgets` (`name`)") - database.execSQL("CREATE INDEX IF NOT EXISTS `index_widgets_profile_id` ON `widgets` (`profile_id`)") + db.execSQL("CREATE TABLE IF NOT EXISTS `widgets` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `profile_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_widgets_name` ON `widgets` (`name`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_widgets_profile_id` ON `widgets` (`profile_id`)") // Insert existing widgets val componentName = ComponentName(application, DDWidgetProvider::class.java) val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName).toList() for (appWidgetId in appWidgetIds) { - database.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES ($appWidgetId, 'Widget $appWidgetId', ${Color.BLACK}, '$defaultProfileId')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES ($appWidgetId, 'Widget $appWidgetId', ${Color.BLACK}, '$defaultProfileId')") } } -} \ No newline at end of file +} + +object MigrationFrom2To3 : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `widget_profiles` ADD COLUMN `updatedAt` INTEGER NOT NULL DEFAULT 0;") + db.execSQL("UPDATE `widget_profiles` SET `updatedAt` = ${System.currentTimeMillis()};") + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt b/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt index d44c90ba..edad01f3 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt @@ -1,9 +1,12 @@ package de.psdev.devdrawer.database -import androidx.recyclerview.widget.DiffUtil -import androidx.room.* +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.PrimaryKey import de.psdev.devdrawer.appwidget.PackageHashInfo -import java.util.* +import java.util.UUID @Entity( tableName = "filters", @@ -30,13 +33,6 @@ data class PackageFilter( @ColumnInfo(name = "profile_id", index = true) val profileId: String ) { - companion object { - val DIFF_CALLBACK = object: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: PackageFilter, newItem: PackageFilter): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: PackageFilter, newItem: PackageFilter): Boolean = oldItem.filter == newItem.filter - } - } - @delegate:Ignore private val filterRegex: Regex by lazy { filter.replace("*", ".*").toRegex() } 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..c88bbde9 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 @@ -19,4 +19,7 @@ abstract class PackageFilterDao : BaseDao() { @Query("DELETE FROM filters WHERE id = :id") abstract suspend fun deleteById(id: String) -} \ No newline at end of file + @Query("DELETE FROM filters WHERE profile_id = :profileId") + abstract suspend fun deleteAllByProfile(profileId: String) + +} diff --git a/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt b/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt new file mode 100644 index 00000000..1762193b --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt @@ -0,0 +1,27 @@ +package de.psdev.devdrawer.database + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import de.psdev.devdrawer.profiles.IPackageFilterRepository +import de.psdev.devdrawer.profiles.IWidgetProfileRepository +import de.psdev.devdrawer.profiles.PackageFilterRepository +import de.psdev.devdrawer.profiles.WidgetProfileRepository +import de.psdev.devdrawer.widgets.IWidgetRepository +import de.psdev.devdrawer.widgets.WidgetRepository + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + abstract fun bindWidgetRepository(impl: WidgetRepository): IWidgetRepository + + @Binds + abstract fun bindWidgetProfileRepository(impl: WidgetProfileRepository): IWidgetProfileRepository + + @Binds + abstract fun bindPackageFilterRepository(impl: PackageFilterRepository): IPackageFilterRepository + +} diff --git a/app/src/main/java/de/psdev/devdrawer/database/Widget.kt b/app/src/main/java/de/psdev/devdrawer/database/Widget.kt index 90fa0707..1fc441e1 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/Widget.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/Widget.kt @@ -1,6 +1,5 @@ package de.psdev.devdrawer.database -import androidx.recyclerview.widget.DiffUtil import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey @@ -26,11 +25,4 @@ data class Widget( var color: Int, @ColumnInfo(name = "profile_id", index = true) var profileId: String -) { - companion object { - val DIFF_CALLBACK = object: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Widget, newItem: Widget): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Widget, newItem: Widget): Boolean = oldItem == newItem - } - } -} \ No newline at end of file +) \ No newline at end of file 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..2b91059c 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..2972709f 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/WidgetProfile.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/WidgetProfile.kt @@ -1,11 +1,10 @@ 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.util.* +import java.time.Instant +import java.util.UUID @Entity(tableName = "widget_profiles") data class WidgetProfile( @@ -13,12 +12,7 @@ data class WidgetProfile( @ColumnInfo(name = "id") val id: String = UUID.randomUUID().toString(), @ColumnInfo(name = "name", typeAffinity = ColumnInfo.TEXT) - var name: String -) { - 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 - } - } -} + var name: String, + @ColumnInfo(name = "updatedAt") + var updatedAt: Instant = Instant.now() +) \ No newline at end of file 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..2cc466b0 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/AddPackageFilterBottomSheetDialogFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/AddPackageFilterBottomSheetDialogFragment.kt deleted file mode 100644 index daafac06..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/AddPackageFilterBottomSheetDialogFragment.kt +++ /dev/null @@ -1,101 +0,0 @@ -package de.psdev.devdrawer.profiles - -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.R -import de.psdev.devdrawer.adapters.PartialMatchAdapter -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.PackageFilter -import de.psdev.devdrawer.databinding.AddPackageFilterBottomSheetDialogFragmentBinding -import de.psdev.devdrawer.receivers.UpdateReceiver -import de.psdev.devdrawer.utils.getExistingPackages -import mu.KLogging -import javax.inject.Inject - -@AndroidEntryPoint -class AddPackageFilterBottomSheetDialogFragment : BottomSheetDialogFragment(), TextWatcher { - companion object : KLogging() - - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - private var _binding: AddPackageFilterBottomSheetDialogFragmentBinding? = null - private val binding get() = _binding!! - - private val navArgs: AddPackageFilterBottomSheetDialogFragmentArgs by navArgs() - - private val appPackages: List by lazy { requireActivity().packageManager.getExistingPackages() } - private val packageNameCompletionAdapter: PartialMatchAdapter by lazy { - PartialMatchAdapter( - requireActivity(), - navArgs.widgetProfileId, - appPackages, - devDrawerDatabase - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - setStyle(DialogFragment.STYLE_NORMAL, R.style.AppBottomSheetDialogTheme) - super.onCreate(savedInstanceState) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = AddPackageFilterBottomSheetDialogFragmentBinding.inflate(inflater).also { - _binding = it - }.root - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - with(binding) { - editPackageName.setAdapter(packageNameCompletionAdapter) - editPackageName.addTextChangedListener(this@AddPackageFilterBottomSheetDialogFragment) - - btnAdd.setOnClickListener { - val filter = editPackageName.text.toString() - if (filter.isNotEmpty()) { - lifecycleScope.launchWhenResumed { - val filters = devDrawerDatabase.packageFilterDao() - .findAllByProfile(navArgs.widgetProfileId) - if (filters.none { it.filter == filter }) { - val packageFilter = PackageFilter( - filter = filter, - profileId = navArgs.widgetProfileId - ) - devDrawerDatabase.packageFilterDao().insert(packageFilter) - editPackageName.text.clear() - UpdateReceiver.send(requireContext()) - dismiss() - } else { - inputLayoutPackage.error = "Filter already exists" - } - } - } - } - } - } - - // ========================================================================================================================== - // TextWatcher - // ========================================================================================================================== - - override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) = Unit - - override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) = Unit - - override fun afterTextChanged(editable: Editable) { - packageNameCompletionAdapter.filter.filter(editable.toString()) - binding.inputLayoutPackage.error = null - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/AppListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/profiles/AppListAdapter.kt deleted file mode 100644 index ebf0e716..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/AppListAdapter.kt +++ /dev/null @@ -1,43 +0,0 @@ -package de.psdev.devdrawer.profiles - -import android.view.ViewGroup -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import de.psdev.devdrawer.appwidget.AppInfo -import de.psdev.devdrawer.databinding.ListItemAppBinding -import de.psdev.devdrawer.utils.layoutInflater - -class AppListAdapter( - private val onAppClickListener: AppInfoActionListener -) : ListAdapter(AppInfo.DIFF_CALLBACK) { - - // ========================================================================================================================== - // ListAdapter - // ========================================================================================================================== - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInfoViewHolder = AppInfoViewHolder( - binding = ListItemAppBinding.inflate(parent.layoutInflater, parent, false), - onClickListener = onAppClickListener - ) - - override fun onBindViewHolder(holder: AppInfoViewHolder, position: Int) { - val appInfo = getItem(position) - holder.bindTo(appInfo) - } - - public override fun getItem(position: Int): AppInfo = super.getItem(position) - - class AppInfoViewHolder( - private val binding: ListItemAppBinding, - private val onClickListener: AppInfoActionListener - ) : RecyclerView.ViewHolder(binding.root) { - fun bindTo(appInfo: AppInfo) { - binding.icon.setImageDrawable(appInfo.appIcon) - binding.text1.text = appInfo.name - binding.root.setOnClickListener { - onClickListener(appInfo) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt deleted file mode 100644 index 2969eefc..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt +++ /dev/null @@ -1,101 +0,0 @@ -package de.psdev.devdrawer.profiles - -import android.content.pm.PackageManager -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -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.toAppInfo -import de.psdev.devdrawer.appwidget.toPackageHashInfo -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.FilterType -import de.psdev.devdrawer.database.PackageFilter -import de.psdev.devdrawer.databinding.AppSignatureChooserBottomSheetDialogFragmentBinding -import de.psdev.devdrawer.receivers.UpdateReceiver -import de.psdev.devdrawer.utils.awaitSubmit -import de.psdev.devdrawer.utils.trace -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import mu.KLogging -import javax.inject.Inject - -@AndroidEntryPoint -class AppSignatureChooserBottomSheetDialogFragment : BottomSheetDialogFragment() { - companion object : KLogging() - - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - private var _binding: AppSignatureChooserBottomSheetDialogFragmentBinding? = null - private val binding get() = _binding!! - - private val navArgs: AppSignatureChooserBottomSheetDialogFragmentArgs by navArgs() - - private val onAppClickListener: AppInfoActionListener = { appInfo -> - lifecycleScope.launch { - val packageFilter = PackageFilter( - filter = appInfo.signatureSha256, - type = FilterType.SIGNATURE, - description = appInfo.name, - profileId = navArgs.widgetProfileId - ) - devDrawerDatabase.packageFilterDao().insert(packageFilter) - UpdateReceiver.send(requireContext()) - dismiss() - } - } - private val appAdapter = AppListAdapter(onAppClickListener) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = AppSignatureChooserBottomSheetDialogFragmentBinding.inflate(inflater).also { - _binding = it - }.root - - @Suppress("DEPRECATION") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - with(binding) { - btnClose.setOnClickListener { dismiss() } - recyclerApps.adapter = appAdapter - lifecycleScope.launchWhenResumed { - withContext(Dispatchers.IO) { - val filters = devDrawerDatabase.packageFilterDao().findAllByProfile(navArgs.widgetProfileId) - val context = requireContext() - val packageManager = context.packageManager - val installedPackages = Firebase.performance.trace("widget_profile_packages") { - packageManager.getInstalledPackages(PackageManager.GET_SIGNATURES) - .asSequence() - .map { it.toPackageHashInfo() } - .distinctBy { it.signatureHashSha256 } - .filter { hashInfo -> filters.none { it.type == FilterType.SIGNATURE && it.filter == hashInfo.signatureHashSha256 } } - .mapNotNull { it.toAppInfo(context) } - .sortedBy { it.name } - .toList() - } - logger.warn { "Installed packages: $installedPackages" } - withContext(Dispatchers.Main) { - appAdapter.awaitSubmit(installedPackages) - progress.hide() - recyclerApps.isVisible = true - } - } - } - } - } - - override fun onDestroyView() { - binding.recyclerApps.adapter = null - super.onDestroyView() - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt b/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt new file mode 100644 index 00000000..064a7e27 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt @@ -0,0 +1,16 @@ +package de.psdev.devdrawer.profiles + +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile + +sealed class DeleteDialogState { + data object Hidden : DeleteDialogState() + data class Showing( + val widgetProfile: WidgetProfile + ) : DeleteDialogState() + + data class InUseError( + val widgetProfile: WidgetProfile, + val widgets: List + ) : DeleteDialogState() +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/FilterPreviewBottomSheetDialogFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/FilterPreviewBottomSheetDialogFragment.kt deleted file mode 100644 index 028f61c2..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/FilterPreviewBottomSheetDialogFragment.kt +++ /dev/null @@ -1,88 +0,0 @@ -package de.psdev.devdrawer.profiles - -import android.content.pm.PackageManager -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -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.toAppInfo -import de.psdev.devdrawer.appwidget.toPackageHashInfo -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.databinding.FilterPreviewBottomSheetDialogFragmentBinding -import de.psdev.devdrawer.utils.awaitSubmit -import de.psdev.devdrawer.utils.trace -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import mu.KLogging -import javax.inject.Inject - -@AndroidEntryPoint -class FilterPreviewBottomSheetDialogFragment : BottomSheetDialogFragment() { - companion object : KLogging() - - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - private var _binding: FilterPreviewBottomSheetDialogFragmentBinding? = null - private val binding get() = _binding!! - - private val navArgs: FilterPreviewBottomSheetDialogFragmentArgs by navArgs() - - private val onAppClickListener: AppInfoActionListener = { appInfo -> - val activity = requireActivity() - startActivity(activity.packageManager.getLaunchIntentForPackage(appInfo.packageName)) - } - private val appAdapter = AppListAdapter(onAppClickListener) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = FilterPreviewBottomSheetDialogFragmentBinding.inflate(inflater).also { - _binding = it - }.root - - @Suppress("DEPRECATION") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - with(binding) { - btnClose.setOnClickListener { dismiss() } - recyclerApps.adapter = appAdapter - lifecycleScope.launchWhenResumed { - withContext(Dispatchers.IO) { - val filter = devDrawerDatabase.packageFilterDao().findById(navArgs.packageFilterId) - ?: throw IllegalArgumentException("Unknown filter") - val context = requireContext() - val packageManager = context.packageManager - val affectedApps = Firebase.performance.trace("profile_filter_preview") { - packageManager.getInstalledPackages(PackageManager.GET_SIGNATURES) - .asSequence() - .map { it.toPackageHashInfo() } - .filter { filter.matches(it) } - .mapNotNull { it.toAppInfo(context) } - .sortedBy { it.name } - .toList() - } - logger.warn { "Affected apps: $affectedApps" } - withContext(Dispatchers.Main) { - appAdapter.awaitSubmit(affectedApps) - progress.hide() - recyclerApps.isVisible = true - } - } - } - } - } - - override fun onDestroyView() { - binding.recyclerApps.adapter = null - super.onDestroyView() - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/IPackageFilterRepository.kt b/app/src/main/java/de/psdev/devdrawer/profiles/IPackageFilterRepository.kt new file mode 100644 index 00000000..849ad033 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/IPackageFilterRepository.kt @@ -0,0 +1,11 @@ +package de.psdev.devdrawer.profiles + +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.WidgetProfile + +interface IPackageFilterRepository { + suspend fun getById(packageFilterId: String): PackageFilter? + suspend fun delete(packageFilter: PackageFilter) + suspend fun save(packageFilter: PackageFilter) + suspend fun saveProfile(widgetProfile: WidgetProfile, filters: List) +} diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/IWidgetProfileRepository.kt b/app/src/main/java/de/psdev/devdrawer/profiles/IWidgetProfileRepository.kt new file mode 100644 index 00000000..17cf9b9f --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/IWidgetProfileRepository.kt @@ -0,0 +1,11 @@ +package de.psdev.devdrawer.profiles + +import de.psdev.devdrawer.database.WidgetProfile +import kotlinx.coroutines.flow.Flow + +interface IWidgetProfileRepository { + fun widgetProfilesFlow(): Flow> + suspend fun delete(widgetProfile: WidgetProfile) + suspend fun findAll(): List + suspend fun create(widgetProfile: WidgetProfile) +} 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..158aba59 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt @@ -0,0 +1,40 @@ +package de.psdev.devdrawer.profiles + +import android.app.Application +import androidx.room.withTransaction +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.WidgetProfile +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 +) : IPackageFilterRepository { + + override suspend fun getById(packageFilterId: String) = + devDrawerDatabase.packageFilterDao().findById(packageFilterId) + + override suspend fun delete(packageFilter: PackageFilter) { + devDrawerDatabase.packageFilterDao().delete(packageFilter) + UpdateReceiver.send(application) + } + + override suspend fun save(packageFilter: PackageFilter) { + devDrawerDatabase.packageFilterDao().insert(packageFilter) + UpdateReceiver.send(application) + } + + override suspend fun saveProfile(widgetProfile: WidgetProfile, filters: List) { + devDrawerDatabase.withTransaction { + devDrawerDatabase.widgetProfileDao().update(widgetProfile) + devDrawerDatabase.packageFilterDao().deleteAllByProfile(widgetProfile.id) + devDrawerDatabase.packageFilterDao().insert(*filters.toTypedArray()) + } + UpdateReceiver.send(application) + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/Types.kt b/app/src/main/java/de/psdev/devdrawer/profiles/Types.kt index 8dc4c39e..e17276c9 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/Types.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/Types.kt @@ -1,9 +1,5 @@ package de.psdev.devdrawer.profiles -import de.psdev.devdrawer.appwidget.AppInfo -import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.WidgetProfile -typealias WidgetActionListener = (WidgetProfile) -> Unit -typealias AppInfoActionListener = (AppInfo) -> Unit -typealias PackageFilterActionListener = (PackageFilter) -> Unit \ No newline at end of file +typealias WidgetActionListener = (WidgetProfile) -> Unit \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetInUseErrorAlertDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetInUseErrorAlertDialog.kt new file mode 100644 index 00000000..3965e8ce --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetInUseErrorAlertDialog.kt @@ -0,0 +1,31 @@ +package de.psdev.devdrawer.profiles + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.psdev.devdrawer.R + +@Composable +fun WidgetInUseErrorAlertDialog( + state: DeleteDialogState.InUseError, + onDismiss: () -> Unit = {} +) { + AlertDialog( + onDismissRequest = { }, + title = { + Text(text = "Error") + }, + text = { + Text(text = "The profile ${state.widgetProfile.name} is used by: \n" + state.widgets.joinToString("\n") { it.name }) + }, + confirmButton = { + TextButton(onClick = { + onDismiss() + }) { + Text(stringResource(id = R.string.close)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt new file mode 100644 index 00000000..277a2305 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt @@ -0,0 +1,76 @@ +package de.psdev.devdrawer.profiles + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import de.psdev.devdrawer.utils.DefaultPreviews +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun WidgetProfileCard( + widgetProfile: WidgetProfile, + onWidgetProfileClick: (WidgetProfile) -> Unit = {}, + onWidgetProfileLongClick: (WidgetProfile) -> Unit = {} +) { + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(8.dp) + .combinedClickable( + onClick = { onWidgetProfileClick(widgetProfile) }, + onLongClick = { onWidgetProfileLongClick(widgetProfile) } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(16.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + text = widgetProfile.name + ) + Spacer(modifier = Modifier.size(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Schedule, + contentDescription = stringResource(id = R.string.last_modified) + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + style = MaterialTheme.typography.bodySmall, + text = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .format(widgetProfile.updatedAt.atZone(ZoneId.systemDefault())) + ) + } + } + } +} + +@DefaultPreviews +@Composable +fun Preview_WidgetProfileCard() { + DevDrawerTheme { + WidgetProfileCard(widgetProfile = WidgetProfile(name = "Test profile")) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/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..12eb7d2a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileList.kt @@ -0,0 +1,141 @@ +package de.psdev.devdrawer.profiles + +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WidgetProfileList( + widgetProfiles: List, + modifier: Modifier = Modifier, + onWidgetProfileClick: (WidgetProfile) -> Unit = {}, + onWidgetProfileLongClick: (WidgetProfile) -> Unit = {}, + onEditProfile: (WidgetProfile) -> Unit = {}, + onDeleteProfile: (WidgetProfile) -> Unit = {} +) { + LazyColumn( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + items(widgetProfiles, key = { it.id }) { widgetProfile -> + val dismissState = rememberSwipeToDismissBoxState() + + LaunchedEffect(dismissState.currentValue) { + when (dismissState.currentValue) { + SwipeToDismissBoxValue.StartToEnd -> { + onEditProfile(widgetProfile) + dismissState.snapTo(SwipeToDismissBoxValue.Settled) + } + + SwipeToDismissBoxValue.EndToStart -> { + onDeleteProfile(widgetProfile) + dismissState.snapTo(SwipeToDismissBoxValue.Settled) + } + + else -> {} + } + } + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val direction = dismissState.dismissDirection + val color by animateColorAsState( + when (direction) { + SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.primaryContainer + SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer + else -> Color.Transparent + }, label = "dismiss_color" + ) + val alignment = when (direction) { + SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart + SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd + else -> Alignment.Center + } + val icon = when (direction) { + SwipeToDismissBoxValue.StartToEnd -> Icons.Default.Edit + SwipeToDismissBoxValue.EndToStart -> Icons.Default.Delete + else -> null + } + val scale by animateFloatAsState( + if (dismissState.targetValue == SwipeToDismissBoxValue.Settled) 0.75f else 1f, + label = "dismiss_scale" + ) + + Box( + Modifier + .fillMaxSize() + .padding(8.dp) + .background(color, MaterialTheme.shapes.medium) + .padding(horizontal = 20.dp), + contentAlignment = alignment + ) { + if (icon != null) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.scale(scale), + tint = when (direction) { + SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.onPrimaryContainer + SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.onSurface + } + ) + } + } + }, + content = { + WidgetProfileCard( + widgetProfile = widgetProfile, + onWidgetProfileClick = onWidgetProfileClick, + onWidgetProfileLongClick = onWidgetProfileLongClick + ) + } + ) + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetProfileList() { + DevDrawerTheme { + WidgetProfileList( + listOf( + WidgetProfile(name = "Profile 1"), + WidgetProfile(name = "Profile 2") + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt deleted file mode 100644 index 769ee55f..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt +++ /dev/null @@ -1,142 +0,0 @@ -package de.psdev.devdrawer.profiles - -import android.database.sqlite.SQLiteConstraintException -import android.os.Bundle -import android.view.* -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.BaseFragment -import de.psdev.devdrawer.R -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.WidgetProfile -import de.psdev.devdrawer.databinding.FragmentWidgetProfileListBinding -import de.psdev.devdrawer.utils.awaitSubmit -import de.psdev.devdrawer.utils.consume -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -import mu.KLogging -import javax.inject.Inject - -@AndroidEntryPoint -class WidgetProfileListFragment: BaseFragment() { - - companion object: KLogging() - - // Dependencies - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - val listAdapter: WidgetProfilesListAdapter = WidgetProfilesListAdapter() - var _selectionTracker: SelectionTracker? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - } - - override fun createViewBinding(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): FragmentWidgetProfileListBinding = - FragmentWidgetProfileListBinding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerProfiles.adapter = listAdapter - val selectionTracker = SelectionTracker.Builder( - "widgetProfile", - binding.recyclerProfiles, - WidgetProfilesItemKeyProvider(listAdapter), - WidgetProfilesDetailsLookup(binding.recyclerProfiles), - StorageStrategy.createStringStorage() - ).withSelectionPredicate(SelectionPredicates.createSelectSingleAnything()).build().also { tracker -> - tracker.onRestoreInstanceState(savedInstanceState) - tracker.addObserver(object: SelectionTracker.SelectionObserver() { - override fun onSelectionChanged() { - super.onSelectionChanged() - activity?.invalidateOptionsMenu() - } - }) - _selectionTracker = tracker - } - listAdapter.selectionTracker = selectionTracker - viewLifecycleOwner.lifecycleScope.launch { - val widgetProfileDao = devDrawerDatabase.widgetProfileDao() - widgetProfileDao.findAllFlow().collect { - logger.warn { "$it" } - listAdapter.awaitSubmit(it) - binding.recyclerProfiles.scrollToPosition(it.indexOfFirst { selectionTracker.isSelected(it.id) }) - } - } - childFragmentManager.setFragmentResultListener("createProfile", viewLifecycleOwner) { _, bundle -> - // We use a String here, but any type that can be put in a Bundle is supported - val result = bundle.getString("profileId") ?: selectionTracker.selection.firstOrNull() ?: "" - selectionTracker.select(result) - } - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - inflater.inflate(R.menu.menu_profiles_list, menu) - val hasSelection = _selectionTracker?.hasSelection() ?: false - menu.findItem(R.id.action_create).isVisible = !hasSelection - menu.findItem(R.id.action_edit).isVisible = hasSelection - menu.findItem(R.id.action_delete).isVisible = hasSelection - } - - override fun onResume() { - super.onResume() - updateToolbarTitle(R.string.profiles) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - R.id.action_create -> consume { - lifecycleScope.launchWhenResumed { - val widgetProfileDao = devDrawerDatabase.widgetProfileDao() - val size = widgetProfileDao.findAll().size - val widgetProfile = WidgetProfile(name = "Profile ${size + 1}") - widgetProfileDao.insert(widgetProfile) - findNavController().navigate(WidgetProfileListFragmentDirections.editWidgetProfile(widgetProfile.id)) - } - } - R.id.action_edit -> consume { - val selectedId = _selectionTracker?.selection?.firstOrNull() - if (selectedId != null) { - findNavController().navigate(WidgetProfileListFragmentDirections.editWidgetProfile(selectedId)) - } - } - R.id.action_delete -> consume { - lifecycleScope.launchWhenStarted { - _selectionTracker?.let { tracker -> - val selectedProfile = tracker.selection.firstOrNull() - if (selectedProfile != null) { - val widgetProfile = devDrawerDatabase.widgetProfileDao().findById(selectedProfile) - if (widgetProfile != null) { - try { - devDrawerDatabase.widgetProfileDao().delete(widgetProfile) - } catch (e: SQLiteConstraintException) { - Snackbar.make(binding.root, R.string.error_profile_in_use, Snackbar.LENGTH_LONG).show() - } - } - tracker.deselect(selectedProfile) - } - } - } - } - else -> super.onOptionsItemSelected(item) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - _selectionTracker?.onSaveInstanceState(outState) - } - - override fun onDestroyView() { - _selectionTracker = null - listAdapter.selectionTracker = null - binding.recyclerProfiles.adapter = null - super.onDestroyView() - } -} diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileRepository.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileRepository.kt new file mode 100644 index 00000000..f7e8f74f --- /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 +) : IWidgetProfileRepository { + + override fun widgetProfilesFlow() = devDrawerDatabase.widgetProfileDao().findAllFlow().distinctUntilChanged() + override suspend fun delete(widgetProfile: WidgetProfile) { + devDrawerDatabase.widgetProfileDao().delete(widgetProfile) + } + + override suspend fun findAll(): List = devDrawerDatabase.widgetProfileDao().findAll() + override 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 deleted file mode 100644 index a321bae2..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesDetailsLookup.kt +++ /dev/null @@ -1,24 +0,0 @@ -package de.psdev.devdrawer.profiles - -import android.view.MotionEvent -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.widget.RecyclerView - -class WidgetProfilesDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup() { - override fun getItemDetails(event: MotionEvent): ItemDetails? { - val view = recyclerView.findChildViewUnder(event.x, event.y) - if (view != null) { - val viewHolder = recyclerView.getChildViewHolder(view) - if (viewHolder is WidgetProfilesListAdapter.WidgetProfileViewHolder) { - val currentItem = viewHolder.currentItem - if (currentItem != null) { - return object : ItemDetails() { - override fun getSelectionKey(): String? = currentItem.id - override fun getPosition(): Int = viewHolder.absoluteAdapterPosition - } - } - } - } - return null - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt deleted file mode 100644 index 392d9881..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.psdev.devdrawer.profiles - -import androidx.recyclerview.selection.ItemKeyProvider - -class WidgetProfilesItemKeyProvider(private val adapter: WidgetProfilesListAdapter): ItemKeyProvider( - SCOPE_MAPPED -) { - override fun getKey(position: Int): String? = adapter.getItem(position).id - override fun getPosition(key: String): Int = adapter.currentList.indexOfFirst { it.id == key } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListAdapter.kt deleted file mode 100644 index 8a3d8065..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListAdapter.kt +++ /dev/null @@ -1,72 +0,0 @@ -package de.psdev.devdrawer.profiles - -import android.view.ViewGroup -import androidx.recyclerview.selection.SelectionTracker -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.utils.consume -import de.psdev.devdrawer.utils.layoutInflater - -class WidgetProfilesListAdapter : ListAdapter(WidgetProfile.DIFF_CALLBACK) { - - var selectionTracker: SelectionTracker? = null - var itemLongClickListener: WidgetActionListener? = null - - // ========================================================================================================================== - // ListAdapter - // ========================================================================================================================== - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WidgetProfileViewHolder { - val onClickListener: WidgetActionListener = { selectedItem: WidgetProfile -> - selectionTracker?.select(selectedItem.id) - } - val onEditClickListener: WidgetActionListener = { selectedItem: WidgetProfile -> - itemLongClickListener?.invoke(selectedItem) - } - return WidgetProfileViewHolder( - binding = ListItemWidgetProfileBinding.inflate(parent.layoutInflater, parent, false), - onClickListener = onClickListener, - onEditClickListener = onEditClickListener - ) - } - - override fun onBindViewHolder(holder: WidgetProfileViewHolder, position: Int) { - val widgetProfile = getItem(position) - val isSelected = selectionTracker?.isSelected(widgetProfile.id) ?: false - holder.bindTo(widgetProfile, isSelected) - } - - public override fun getItem(position: Int): WidgetProfile = super.getItem(position) - - class WidgetProfileViewHolder( - private val binding: ListItemWidgetProfileBinding, - private val onClickListener: WidgetActionListener, - private val onEditClickListener: WidgetActionListener - ) : RecyclerView.ViewHolder(binding.root) { - var currentItem: WidgetProfile? = null - private set - - fun bindTo(item: WidgetProfile, isActivated: Boolean = false) { - currentItem = item - with(binding) { - with(txtName) { - text = item.name - } - with(root) { - setOnClickListener { - onClickListener(item) - } - setOnLongClickListener { - consume { - onEditClickListener(item) - } - } - } - root.isActivated = isActivated - } - } - } -} \ No newline at end of file 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..66011573 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt @@ -0,0 +1,58 @@ +package de.psdev.devdrawer.profiles + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.analytics.Events +import de.psdev.devdrawer.analytics.TrackingService +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.ui.UiState +import de.psdev.devdrawer.widgets.IWidgetRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WidgetProfilesViewModel @Inject constructor( + private val widgetProfileRepository: IWidgetProfileRepository, + private val widgetRepository: IWidgetRepository, + private val trackingService: TrackingService +) : ViewModel() { + + val viewState = widgetProfileRepository.widgetProfilesFlow() + .map { UiState.Success(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading) + + fun prepareProfileDeletion(widgetProfile: WidgetProfile, onResult: (DeleteDialogState) -> Unit) { + viewModelScope.launch { + val widgets = widgetRepository.findWidgetsForProfile(widgetProfile.id) + onResult( + if (widgets.isNotEmpty()) { + DeleteDialogState.InUseError(widgetProfile = widgetProfile, widgets = widgets) + } else { + DeleteDialogState.Showing(widgetProfile) + } + ) + } + } + + fun deleteProfile(widgetProfile: WidgetProfile) { + viewModelScope.launch { + widgetProfileRepository.delete(widgetProfile) + trackingService.trackAction(Events.PROFILE_DELETED) + } + } + + fun createNewProfile(onCreated: (WidgetProfile) -> Unit) { + viewModelScope.launch { + val size = widgetProfileRepository.findAll().size + val widgetProfile = WidgetProfile(name = "Profile ${size + 1}") + widgetProfileRepository.create(widgetProfile) + trackingService.trackAction(Events.PROFILE_CREATED) + onCreated(widgetProfile) + } + } + +} 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..304f5299 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt @@ -0,0 +1,279 @@ +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +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.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +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.platform.LocalResources +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.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable +import androidx.hilt.lifecycle.viewmodel.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)) + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + PlainTooltip { + Text(text = "Toggle system apps") + } + }, + state = rememberTooltipState() + ) { + IconButton( + 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 resources = LocalResources.current + val bitmap = createBitmap(10, 10) + AddAppSignaturePackageFilterDialog( + viewState = ViewState.Loaded( + listOf( + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 0, + 0, + "" + ), + AppInfo( + name = "App 1", + packageName = "com.example.app1", + bitmap.toDrawable(resources), + 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..a9bd0839 --- /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.apps.AppsService +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.toAppInfo +import de.psdev.devdrawer.database.PackageFilter +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..ba6c7d82 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialog.kt @@ -0,0 +1,137 @@ +package de.psdev.devdrawer.profiles.ui.editor + +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.Row +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.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.text.intl.Locale +import androidx.compose.ui.text.toUpperCase +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.lifecycle.viewmodel.compose.hiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel.ViewState.Error +import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel.ViewState.Loaded +import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel.ViewState.Loading +import de.psdev.devdrawer.ui.autocomplete.AutoCompleteTextView +import de.psdev.devdrawer.ui.loading.LoadingView +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@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)) + HorizontalDivider() + 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).toUpperCase(Locale.current)) + } + TextButton(onClick = { addFilter(text) }, enabled = text.isNotBlank()) { + Text(text = stringResource(id = R.string.add).toUpperCase(Locale.current)) + } + } + } + } + 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..bf9ec008 --- /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.apps.AppsService +import de.psdev.devdrawer.database.PackageFilter +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..ac2846b3 --- /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 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.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.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +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() + .clickable { onAppClicked(appInfo) } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val icon = remember(appInfo.appIcon) { + appInfo.appIcon.toBitmap().asImageBitmap() + } + Image( + bitmap = icon, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Column( + modifier = Modifier + .padding(start = 8.dp) + .weight(1f) + ) { + Text( + text = appInfo.name, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "${appInfo.packageName} (${appInfo.versionName} - ${appInfo.versionCode})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Preview +@Composable +fun Preview_AppInfoItem() { + val resources = LocalResources.current + val bitmap = createBitmap(1, 1) + DevDrawerTheme { + Surface { + AppInfoItem( + appInfo = AppInfo( + name = "Example App", + packageName = "com.example.app", + appIcon = bitmap.toDrawable(resources), + firstInstallTime = 0, + lastUpdateTime = 0, + signatureHashSha256 = "", + versionName = "1.0.0", + versionCode = 123 + ) + ) + } + } +} 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..20fba914 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt @@ -0,0 +1,34 @@ +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 androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.toUpperCase +import de.psdev.devdrawer.R + +@Composable +fun DeletePackageFilterDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(id = R.string.delete_filter)) + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = R.string.cancel).toUpperCase(Locale.current)) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(id = R.string.yes).toUpperCase(Locale.current)) + } + } + ) + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterInfoDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterInfoDialog.kt new file mode 100644 index 00000000..cccd81c9 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterInfoDialog.kt @@ -0,0 +1,140 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.text.selection.SelectionContainer +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.text.font.FontFamily +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 de.psdev.devdrawer.R +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.ui.dialog.DefaultDialog +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun PackageFilterInfoDialog( + packageFilter: PackageFilter, + onDismiss: () -> Unit +) { + DefaultDialog( + onDismissRequest = onDismiss, + titleContent = { + 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( + painter = painterResource(id = iconRes), + contentDescription = null + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource( + id = when (packageFilter.type) { + FilterType.PACKAGE_NAME -> R.string.add_package_name + FilterType.SIGNATURE -> R.string.app_signature + } + ) + ) + } + }, + bottomContent = { + TextButton( + modifier = Modifier.align(Alignment.End), + onClick = onDismiss + ) { + Text(text = stringResource(id = R.string.close).toUpperCase(Locale.current)) + } + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + if (packageFilter.type == FilterType.SIGNATURE && !packageFilter.description.isNullOrBlank()) { + Text( + text = stringResource(id = R.string.name), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = packageFilter.description, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + Text( + text = stringResource(id = R.string.packagefilter), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + SelectionContainer { + Text( + text = packageFilter.filter, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + ) + } + } + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_PackageFilterInfoDialog_Signature() { + DevDrawerTheme { + Surface { + PackageFilterInfoDialog( + packageFilter = PackageFilter( + profileId = "1", + filter = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", + type = FilterType.SIGNATURE, + description = "Example App" + ), + onDismiss = {} + ) + } + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_PackageFilterInfoDialog_PackageName() { + DevDrawerTheme { + Surface { + PackageFilterInfoDialog( + packageFilter = PackageFilter( + profileId = "1", + filter = "com.example.*", + type = FilterType.PACKAGE_NAME + ), + onDismiss = {} + ) + } + } +} 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..de34bb92 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt @@ -0,0 +1,119 @@ +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.text.intl.Locale +import androidx.compose.ui.text.toUpperCase +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.res.ResourcesCompat +import androidx.hilt.lifecycle.viewmodel.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 + +@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).toUpperCase(Locale.current)) + } + } + ) { + 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..4fb690a1 --- /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.apps.AppsService +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.database.PackageFilter +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..35b1b676 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt @@ -0,0 +1,412 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +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.Save +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.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.lifecycle.viewmodel.compose.hiltViewModel +import de.psdev.devdrawer.AppBarActionsProvider +import de.psdev.devdrawer.ProvideMenu +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 java.util.UUID + +@Composable +fun WidgetProfileEditor( + profileId: String, + menuCallback: AppBarActionsProvider, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: WidgetProfileEditorViewModel = hiltViewModel( + creationCallback = { factory: WidgetProfileEditorViewModel.Factory -> + factory.create(profileId) + } + ) +) { + val viewState by viewModel.state.collectAsState(initial = WidgetProfileEditorViewState.Empty) + var currentDialog by remember { mutableStateOf(WidgetProfileEditorDialogs.None) } + + val hasChanges = viewState.isDirty + + BackHandler { + if (hasChanges) { + currentDialog = WidgetProfileEditorDialogs.DiscardChanges + } else { + onBack() + } + } + + ProvideMenu(menuCallback, viewState) { + if (hasChanges) { + IconButton(onClick = { viewModel.saveChanges(viewState) }) { + Icon(imageVector = Icons.Outlined.Save, contentDescription = stringResource(id = R.string.save)) + } + } + } + + WidgetProfileEditor( + viewState = viewState, + modifier = modifier.fillMaxSize(), + onNameChange = { + viewModel.onNameChanged(it) + }, + onAddPackageFilterClick = { + currentDialog = WidgetProfileEditorDialogs.AddPackageNamePackageFilter(viewState.packageFilters) + }, + onAddAppSignatureClick = { + currentDialog = WidgetProfileEditorDialogs.AddAppSignaturePackageFilter(viewState.packageFilters) + }, + onPackageFilterPreviewClick = { + currentDialog = WidgetProfileEditorDialogs.PackageFilterPreview(it) + }, + onPackageFilterInfoClick = { + currentDialog = WidgetProfileEditorDialogs.PackageFilterInfo(it) + }, + 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 -> + 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 -> + 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 -> PackageFilterInfoDialog( + packageFilter = dialog.packageFilter, + onDismiss = { + currentDialog = WidgetProfileEditorDialogs.None + } + ) + + is WidgetProfileEditorDialogs.DeletePackageFilter -> DeletePackageFilterDialog( + onDismiss = { + currentDialog = WidgetProfileEditorDialogs.None + }, + onConfirm = { + viewModel.deleteFilter(dialog.packageFilter) + currentDialog = WidgetProfileEditorDialogs.None + } + ) + + WidgetProfileEditorDialogs.DiscardChanges -> AlertDialog( + onDismissRequest = { currentDialog = WidgetProfileEditorDialogs.None }, + title = { Text(text = stringResource(R.string.discard_changes)) }, + text = { Text(text = stringResource(R.string.discard_changes_confirmation)) }, + confirmButton = { + TextButton(onClick = { + currentDialog = WidgetProfileEditorDialogs.None + viewModel.clearLocalChanges() + onBack() + }) { + Text(text = stringResource(R.string.discard)) + } + }, + dismissButton = { + TextButton(onClick = { currentDialog = WidgetProfileEditorDialogs.None }) { + Text(text = stringResource(R.string.cancel)) + } + } + ) + } +} + +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() + + data object DiscardChanges : WidgetProfileEditorDialogs() +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun WidgetProfileEditor( + viewState: WidgetProfileEditorViewState, + modifier: Modifier = Modifier, + onNameChange: (String) -> 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.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + } + } else { + Box(modifier = modifier.fillMaxSize()) { + Column { + Surface(modifier = Modifier.wrapContentHeight(), shadowElevation = 2.dp) { + Column( + modifier = Modifier + .wrapContentHeight() + .padding(8.dp) + ) { + WidgetProfileName( + widgetName = viewState.widgetName ?: widgetProfile.name, + onNameChange = onNameChange + ) + 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).toUpperCase(Locale.current)) + } + 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).toUpperCase(Locale.current)) + } + } + } + } + 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 + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun WidgetProfileName( + widgetName: String, + onNameChange: (String) -> 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)) } + ) + } +} + +@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", + isDirty = true + ) + ) + } +} 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..cb68e00b --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt @@ -0,0 +1,93 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.IPackageFilterRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = WidgetProfileEditorViewModel.Factory::class) +class WidgetProfileEditorViewModel @AssistedInject constructor( + @Assisted private val profileId: String, + private val database: DevDrawerDatabase, + private val packageFilterRepository: IPackageFilterRepository +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(profileId: String): WidgetProfileEditorViewModel + } + + private val widgetNameState: MutableStateFlow = MutableStateFlow(null) + private val packageFiltersState: MutableStateFlow?> = MutableStateFlow(null) + + private val dbFiltersFlow = database.packageFilterDao().findAllByProfileFlow(profileId) + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val state = combine( + database.widgetProfileDao().widgetProfileWithIdObservable(profileId), + dbFiltersFlow, + widgetNameState, + packageFiltersState + ) { widgetProfile, dbPackageFilters, name, inMemoryFilters -> + val currentFilters = inMemoryFilters ?: dbPackageFilters + val currentName = name ?: widgetProfile?.name.orEmpty() + + val nameChanged = name != null && name != widgetProfile?.name + // Use a set-based comparison to avoid issues with order or duplicate objects with same content + val filtersChanged = inMemoryFilters != null && inMemoryFilters.toSet() != dbPackageFilters.toSet() + + WidgetProfileEditorViewState( + widgetProfile = widgetProfile, + widgetName = currentName, + packageFilters = currentFilters, + isDirty = nameChanged || filtersChanged + ) + } + + fun onNameChanged(name: String) { + widgetNameState.value = name + } + + fun saveChanges(viewState: WidgetProfileEditorViewState) { + viewModelScope.launch { + val widgetProfile = viewState.widgetProfile ?: return@launch + val newName = viewState.widgetName ?: return@launch + packageFilterRepository.saveProfile(widgetProfile.copy(name = newName), viewState.packageFilters) + // Reset local state after save + clearLocalChanges() + } + } + + fun addPackageFilter(packageFilter: PackageFilter) { + viewModelScope.launch { + val dbFilters = dbFiltersFlow.value + val currentFilters = packageFiltersState.value ?: dbFilters + val newFilters = currentFilters + packageFilter + packageFiltersState.value = if (newFilters.toSet() == dbFilters.toSet()) null else newFilters + } + } + + fun deleteFilter(packageFilter: PackageFilter) { + viewModelScope.launch { + val dbFilters = dbFiltersFlow.value + val currentFilters = packageFiltersState.value ?: dbFilters + val newFilters = currentFilters.filter { it.id != packageFilter.id } + packageFiltersState.value = if (newFilters.toSet() == dbFilters.toSet()) null else newFilters + } + } + + fun clearLocalChanges() { + widgetNameState.value = null + packageFiltersState.value = null + } +} 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..195ab358 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt @@ -0,0 +1,17 @@ +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(), + val isDirty: Boolean = false +) { + companion object { + val Empty = WidgetProfileEditorViewState() + } +} 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..4349d68c --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt @@ -0,0 +1,188 @@ +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.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.lifecycle.viewmodel.compose.hiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.WidgetProfile +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.UiState +import de.psdev.devdrawer.ui.loading.LoadingView +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import java.util.UUID + +@Composable +fun WidgetProfilesScreen( + viewModel: WidgetProfilesViewModel = hiltViewModel(), + onEditProfile: (WidgetProfile) -> Unit +) { + var deleteDialogShown by remember { mutableStateOf(DeleteDialogState.Hidden) } + val viewState by viewModel.viewState.collectAsState() + + val onDeleteProfile: (WidgetProfile) -> Unit = { widgetProfile -> + viewModel.prepareProfileDeletion(widgetProfile) { state -> + deleteDialogShown = state + } + } + + WidgetProfileListScreen( + viewState = viewState, + onWidgetProfileClick = onEditProfile, + onWidgetProfileLongClick = onDeleteProfile, + onEditProfile = onEditProfile, + onDeleteProfile = onDeleteProfile, + onCreateWidgetProfileClick = { + viewModel.createNewProfile { widgetProfile -> + onEditProfile(widgetProfile) + } + } + ) + when (val state = deleteDialogShown) { + DeleteDialogState.Hidden -> Unit + is DeleteDialogState.Showing -> AlertDialog( + onDismissRequest = { deleteDialogShown = DeleteDialogState.Hidden }, + title = { + Text(text = "Confirm") + }, + text = { + Text(text = "Do you really want to delete the profile '${state.widgetProfile.name}'?") + }, + confirmButton = { + TextButton(onClick = { + 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: UiState>, + onWidgetProfileClick: (WidgetProfile) -> Unit = {}, + onWidgetProfileLongClick: (WidgetProfile) -> Unit = {}, + onEditProfile: (WidgetProfile) -> Unit = {}, + onDeleteProfile: (WidgetProfile) -> Unit = {}, + onCreateWidgetProfileClick: () -> Unit = {} +) { + when (viewState) { + is UiState.Loading, is UiState.Error -> LoadingView() + is UiState.Success -> { + 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, + onEditProfile = onEditProfile, + onDeleteProfile = onDeleteProfile + ) + 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 = UiState.Success(emptyList()) + ) + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetProfileListScreen_Profiles() { + DevDrawerTheme { + WidgetProfileListScreen( + viewState = UiState.Success( + listOf( + WidgetProfile(UUID.randomUUID().toString(), "Profile 1"), + WidgetProfile(UUID.randomUUID().toString(), "Profile 2"), + ) + ) + ) + } +} 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..976a6adc --- /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.appwidget.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..61b971c8 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/ListPreference.kt @@ -0,0 +1,164 @@ +package de.psdev.devdrawer.settings + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.semantics.Role +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() + .clickable { selectionDialog = true } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + text = label + ) + Text( + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + 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 }, + role = Role.RadioButton + ) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selection == item, + onClick = null + ) + Text( + text = values[item].orEmpty(), + style = MaterialTheme.typography.bodyLarge, + 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/PreferenceCategory.kt b/app/src/main/java/de/psdev/devdrawer/settings/PreferenceCategory.kt new file mode 100644 index 00000000..0393cbf2 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/PreferenceCategory.kt @@ -0,0 +1,22 @@ +package de.psdev.devdrawer.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PreferenceCategory( + title: String, + modifier: Modifier = Modifier +) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = modifier + .padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 8.dp) + ) +} diff --git a/app/src/main/java/de/psdev/devdrawer/settings/PreferenceKeys.kt b/app/src/main/java/de/psdev/devdrawer/settings/PreferenceKeys.kt new file mode 100644 index 00000000..8e07adc4 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/PreferenceKeys.kt @@ -0,0 +1,9 @@ +package de.psdev.devdrawer.settings + +object PreferenceKeys { + const val SHOW_ACTIVITY_CHOICE = "show_activity_choice" + const val SORT_ORDER = "sort_order" + const val THEME = "theme" + const val DYNAMIC_COLOR = "dynamic_color" + const val ANALYTICS_OPT_IN = "feature_analytics_opted_in" +} diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt b/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt deleted file mode 100644 index f03d2127..00000000 --- a/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt +++ /dev/null @@ -1,72 +0,0 @@ -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 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.config.RemoteConfigService -import java.util.* -import javax.inject.Inject - -@AndroidEntryPoint -class SettingsFragment: PreferenceFragmentCompat() { - - @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 - } - } - 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..55dd0bb4 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt @@ -0,0 +1,161 @@ +package de.psdev.devdrawer.settings + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +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.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +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 +fun SettingsScreen( + onAboutClick: () -> Unit = {}, + viewModel: SettingsViewModel = hiltViewModel() +) { + val viewState by viewModel.viewState.collectAsState() + + SettingsScreen( + viewState = viewState, + onAboutClick = onAboutClick, + onActivityChooserChanged = { + viewModel.onActivityChooserChanged(it) + }, + onSortOrderChanged = { + viewModel.onSortOrderChanged(it) + }, + onThemeSettingChanged = { + viewModel.onThemeSettingChanged(it) + }, + onDynamicColorChanged = { + viewModel.onDynamicColorChanged(it) + }, + onAnalyticsOptInChanged = { + viewModel.onAnalyticsOptInChanged(it) + } + ) +} + +@Composable +fun SettingsScreen( + viewState: SettingsViewModel.ViewState, + onAboutClick: () -> Unit = {}, + onActivityChooserChanged: (Boolean) -> Unit = {}, + onSortOrderChanged: (SortOrder) -> Unit = {}, + onThemeSettingChanged: (ThemeSetting) -> Unit = {}, + onDynamicColorChanged: (Boolean) -> Unit = {}, + onAnalyticsOptInChanged: (Boolean) -> Unit = {}, +) { + when (viewState) { + SettingsViewModel.ViewState.Loading -> LoadingView(modifier = Modifier.fillMaxSize()) + is SettingsViewModel.ViewState.Loaded -> { + val settings = viewState.settings + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + PreferenceCategory(title = stringResource(id = R.string.settings_category_general)) + SwitchPreference( + text = stringResource(id = R.string.pref_show_activity_choice_title), + enabled = settings.activityChooserEnabled + ) { + onActivityChooserChanged(it) + } + HorizontalDivider() + val sortOrderLabels = stringArrayResource(id = R.array.sort_order_labels) + ListPreference( + label = stringResource(id = R.string.pref_sort_order_title), + values = SortOrder.entries.mapIndexed { index, sortOrder -> + sortOrder to sortOrderLabels[index] + }.toMap(), + currentValue = settings.defaultSortOrder + ) { + onSortOrderChanged(it) + } + + PreferenceCategory(title = stringResource(id = R.string.settings_category_ui)) + val themeLabels = stringArrayResource(id = R.array.theme_labels) + ListPreference( + label = stringResource(id = R.string.pref_theme_title), + values = ThemeSetting.entries.mapIndexed { index, themeSetting -> + themeSetting to themeLabels[index] + }.toMap(), + currentValue = settings.themeSetting + ) { + onThemeSettingChanged(it) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + HorizontalDivider() + SwitchPreference( + text = stringResource(id = R.string.pref_dynamic_color_title), + summary = stringResource(id = R.string.pref_dynamic_color_summary), + enabled = settings.dynamicColorEnabled + ) { + onDynamicColorChanged(it) + } + } + + AnimatedVisibility(visible = viewState.analyticsVisible) { + Column { + PreferenceCategory(title = stringResource(id = R.string.settings_category_analytics)) + SwitchPreference( + text = stringResource(id = R.string.pref_feature_analytics_opted_in_title), + enabled = settings.analyticsOptIn + ) { + onAnalyticsOptInChanged(it) + } + } + } + + PreferenceCategory(title = stringResource(id = R.string.settings_category_about)) + Text( + text = stringResource(id = R.string.app_info), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clickable { onAboutClick() } + .padding(16.dp) + ) + } + } + } +} + +@Preview +@Composable +fun Preview_SettingsScreen() { + DevDrawerTheme { + SettingsScreen( + viewState = SettingsViewModel.ViewState.Loaded( + settings = SettingsViewModel.Settings( + activityChooserEnabled = true, + defaultSortOrder = SortOrder.LAST_UPDATED, + themeSetting = ThemeSetting.SYSTEM, + dynamicColorEnabled = true, + analyticsOptIn = true + ), + analyticsVisible = true + ) + ) + } +} 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..3a1a2106 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsViewModel.kt @@ -0,0 +1,126 @@ +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.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.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.stateIn +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: StateFlow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, _ -> + trySendBlocking(sharedPreferences.loadSettings(application)) + } + send(sharedPreferences.loadSettings(application)) + sharedPreferences.registerOnSharedPreferenceChangeListener(listener) + awaitClose { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), sharedPreferences.loadSettings(application)) + + val viewState: MutableStateFlow = MutableStateFlow(ViewState.Loading) + + init { + viewModelScope.launch { + val analyticsEnabled = remoteConfigService.getBoolean(TrackingService.CONFIG_KEY_ENABLED) + persistedSettings.collect { settings -> + viewState.value = ViewState.Loaded( + analyticsVisible = analyticsEnabled, + settings = settings + ) + } + } + } + + fun onActivityChooserChanged(enabled: Boolean) { + sharedPreferences.edit { putBoolean(PreferenceKeys.SHOW_ACTIVITY_CHOICE, enabled) } + onSettingsUpdated() + } + + fun onSortOrderChanged(sortOrder: SortOrder) { + sharedPreferences.edit { putString(PreferenceKeys.SORT_ORDER, sortOrder.name) } + onSettingsUpdated() + } + + fun onThemeSettingChanged(themeSetting: ThemeSetting) { + sharedPreferences.edit { putString(PreferenceKeys.THEME, themeSetting.name) } + onSettingsUpdated() + } + + fun onDynamicColorChanged(enabled: Boolean) { + sharedPreferences.edit { putBoolean(PreferenceKeys.DYNAMIC_COLOR, enabled) } + onSettingsUpdated() + } + + fun onAnalyticsOptInChanged(enabled: Boolean) { + sharedPreferences.edit { putBoolean(PreferenceKeys.ANALYTICS_OPT_IN, enabled) } + onSettingsUpdated() + } + + // ========================================================================================================================== + // Private API + // ========================================================================================================================== + + 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 themeSetting: ThemeSetting, + val dynamicColorEnabled: Boolean, + val analyticsOptIn: Boolean + ) + + private fun SharedPreferences.loadSettings(application: Application): Settings = Settings( + activityChooserEnabled = getBoolean( + PreferenceKeys.SHOW_ACTIVITY_CHOICE, + application.resources.getBoolean(de.psdev.devdrawer.R.bool.pref_show_activity_choice_default) + ), + defaultSortOrder = SortOrder.valueOf( + getString(PreferenceKeys.SORT_ORDER, null) + ?: application.getString(de.psdev.devdrawer.R.string.pref_sort_order_default) + ), + themeSetting = ThemeSetting.valueOf( + getString(PreferenceKeys.THEME, null) + ?: application.getString(de.psdev.devdrawer.R.string.pref_theme_default) + ), + dynamicColorEnabled = getBoolean( + PreferenceKeys.DYNAMIC_COLOR, + application.resources.getBoolean(de.psdev.devdrawer.R.bool.pref_dynamic_color_default) + ), + analyticsOptIn = getBoolean(PreferenceKeys.ANALYTICS_OPT_IN, false) + ) + +} 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..c0239419 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/SwitchPreference.kt @@ -0,0 +1,65 @@ +package de.psdev.devdrawer.settings + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +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, + summary: String? = null, + onChange: (Boolean) -> Unit = {} +) { + Row( + modifier = Modifier + .defaultMinSize(minHeight = 64.dp) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + text = text + ) + if (summary != null) { + Text( + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + text = summary + ) + } + } + 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, summary = "This is a summary") + } +} + +@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/settings/ThemeSetting.kt b/app/src/main/java/de/psdev/devdrawer/settings/ThemeSetting.kt new file mode 100644 index 00000000..f3036df8 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/ThemeSetting.kt @@ -0,0 +1,5 @@ +package de.psdev.devdrawer.settings + +enum class ThemeSetting { + SYSTEM, LIGHT, DARK +} \ 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/UiState.kt b/app/src/main/java/de/psdev/devdrawer/ui/UiState.kt new file mode 100644 index 00000000..135aeb4e --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/UiState.kt @@ -0,0 +1,7 @@ +package de.psdev.devdrawer.ui + +sealed class UiState { + data object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val cause: Throwable) : UiState() +} 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..429dec97 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt @@ -0,0 +1,162 @@ +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.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentHeight +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.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.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(autoCorrectEnabled = 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..16c99083 --- /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.HorizontalDivider +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)) + HorizontalDivider() + 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..a853a031 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/Color.kt @@ -0,0 +1,15 @@ +package de.psdev.devdrawer.ui.theme + +import androidx.compose.ui.graphics.Color + +val Amber500 = Color(0xFFFFC107) +val Amber700 = Color(0xFFFFA000) +val Amber200 = Color(0xFFFFE082) + +val Orange500 = Color(0xFFFF9800) +val Orange700 = Color(0xFFF57C00) +val Orange200 = Color(0xFFFFCC80) + +val DeepOrange500 = Color(0xFFFF5722) +val DeepOrange700 = Color(0xFFE64A19) +val DeepOrange200 = Color(0xFFFFAB91) 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..c6a68ff4 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/Theme.kt @@ -0,0 +1,73 @@ +package de.psdev.devdrawer.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Amber200, + onPrimary = Color(0xFF422D00), + primaryContainer = Color(0xFF5E4200), + onPrimaryContainer = Amber200, + secondary = DeepOrange200, + onSecondary = Color(0xFF630E00), + secondaryContainer = Color(0xFF8E1400), + onSecondaryContainer = DeepOrange200, + background = Color(0xFF1E1B16), + onBackground = Color(0xFFE9E1D9), + surface = Color(0xFF1E1B16), + onSurface = Color(0xFFE9E1D9), + surfaceVariant = Color(0xFF4D4639), + onSurfaceVariant = Color(0xFFD0C5B4), + outline = Color(0xFF999080) +) + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF7B5800), + onPrimary = Color.White, + primaryContainer = Amber200, + onPrimaryContainer = Color(0xFF261900), + secondary = Color(0xFFB11D00), + onSecondary = Color.White, + secondaryContainer = DeepOrange200, + onSecondaryContainer = Color(0xFF3B0500), + background = Color(0xFFFFFBFF), + onBackground = Color(0xFF1E1B16), + surface = Color(0xFFFFFBFF), + onSurface = Color(0xFF1E1B16), + surfaceVariant = Color(0xFFEDE1CF), + onSurfaceVariant = Color(0xFF4D4639), + outline = Color(0xFF7F7667) +) + +@Composable +fun DevDrawerTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + // typography = Typography, + // shapes = Shapes, + content = content + ) +} 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/updates/UpdateManager.kt b/app/src/main/java/de/psdev/devdrawer/updates/UpdateManager.kt new file mode 100644 index 00000000..a675041a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/updates/UpdateManager.kt @@ -0,0 +1,62 @@ +package de.psdev.devdrawer.updates + +import android.app.Activity +import android.content.Context +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.UpdateAvailability +import com.google.android.play.core.ktx.isFlexibleUpdateAllowed +import com.google.android.play.core.ktx.isImmediateUpdateAllowed +import dagger.hilt.android.qualifiers.ApplicationContext +import mu.KLogging +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UpdateManager @Inject constructor( + @param:ApplicationContext private val context: Context +) { + companion object : KLogging() { + const val REQUEST_CODE_UPDATE = 1001 + } + + private val appUpdateManager = AppUpdateManagerFactory.create(context) + + fun checkForUpdates(activity: Activity) { + val appUpdateInfoTask = appUpdateManager.appUpdateInfo + + appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + if (appUpdateInfo.isImmediateUpdateAllowed) { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activity, + AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), + REQUEST_CODE_UPDATE + ) + } else if (appUpdateInfo.isFlexibleUpdateAllowed) { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activity, + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(), + REQUEST_CODE_UPDATE + ) + } + } + } + } + + fun resumeUpdate(activity: Activity) { + appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activity, + AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), + REQUEST_CODE_UPDATE + ) + } + } + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/utils/Android.kt b/app/src/main/java/de/psdev/devdrawer/utils/Android.kt deleted file mode 100644 index cc70c61c..00000000 --- a/app/src/main/java/de/psdev/devdrawer/utils/Android.kt +++ /dev/null @@ -1,12 +0,0 @@ -package de.psdev.devdrawer.utils - -import android.os.Build - -inline fun supportsLollipop(code: () -> Unit) = supportsVersion(Build.VERSION_CODES.LOLLIPOP, code) -inline fun supportsOreo(code: () -> Unit) = supportsVersion(Build.VERSION_CODES.O, code) - -inline fun supportsVersion(sdk: Int, code: () -> Unit) { - if (Build.VERSION.SDK_INT >= sdk) { - code() - } -} diff --git a/app/src/main/java/de/psdev/devdrawer/utils/Bindings.kt b/app/src/main/java/de/psdev/devdrawer/utils/Bindings.kt deleted file mode 100644 index 2ff83d2d..00000000 --- a/app/src/main/java/de/psdev/devdrawer/utils/Bindings.kt +++ /dev/null @@ -1,17 +0,0 @@ -package de.psdev.devdrawer.utils - -import android.widget.Button -import android.widget.EditText -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import reactivecircus.flowbinding.android.view.clicks -import reactivecircus.flowbinding.android.widget.textChanges - -fun MutableStateFlow.receiveTextChangesFrom(editText: EditText) = editText.textChanges() - .map { it.toString() } - .onEach { value = it } - -fun MutableSharedFlow.receiveClicksFrom(button: Button) = button.clicks() - .onEach { emit(it) } 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/HelperKt.kt b/app/src/main/java/de/psdev/devdrawer/utils/HelperKt.kt deleted file mode 100644 index 4c584960..00000000 --- a/app/src/main/java/de/psdev/devdrawer/utils/HelperKt.kt +++ /dev/null @@ -1,6 +0,0 @@ -package de.psdev.devdrawer.utils - -inline fun consume(block: () -> Unit): Boolean { - block() - return true -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/utils/ListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/utils/ListAdapter.kt deleted file mode 100644 index 921e9b1f..00000000 --- a/app/src/main/java/de/psdev/devdrawer/utils/ListAdapter.kt +++ /dev/null @@ -1,14 +0,0 @@ -package de.psdev.devdrawer.utils - -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -suspend fun ListAdapter.awaitSubmit( - list: List -) = suspendCancellableCoroutine { cont -> - submitList(list) { - cont.resume(value = Unit) - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/utils/PackageManagerKt.kt b/app/src/main/java/de/psdev/devdrawer/utils/PackageManagerKt.kt deleted file mode 100644 index 6669454c..00000000 --- a/app/src/main/java/de/psdev/devdrawer/utils/PackageManagerKt.kt +++ /dev/null @@ -1,32 +0,0 @@ -package de.psdev.devdrawer.utils - -import android.content.Intent -import android.content.pm.PackageManager -import java.text.Collator - -/** - * Method to get all apps installed and return as List - */ -fun PackageManager.getExistingPackages(): List { - val intent = Intent(Intent.ACTION_MAIN, null) - intent.addCategory(Intent.CATEGORY_LAUNCHER) - val activities = queryIntentActivities(intent, 0) - - val appSet = mutableSetOf() - - activities.forEach { resolveInfo -> - var appName = resolveInfo.activityInfo.applicationInfo.packageName - appSet.add(appName) - while (appName.isNotEmpty()) { - val lastIndex = appName.lastIndexOf(".") - if (lastIndex > 0) { - appName = appName.substring(0, lastIndex) - appSet.add(appName + ".*") - } else { - appName = "" - } - } - } - - return appSet.toList().sortedWith(Collator.getInstance()) -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/utils/ViewModels.kt b/app/src/main/java/de/psdev/devdrawer/utils/ViewModels.kt deleted file mode 100644 index 244aacd4..00000000 --- a/app/src/main/java/de/psdev/devdrawer/utils/ViewModels.kt +++ /dev/null @@ -1,11 +0,0 @@ -package de.psdev.devdrawer.utils - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider.Factory - -fun simpleFactory(block: () -> T): Factory = object : Factory { - @Suppress("UNCHECKED_CAST") - 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/utils/Views.kt b/app/src/main/java/de/psdev/devdrawer/utils/Views.kt deleted file mode 100644 index 16c996c7..00000000 --- a/app/src/main/java/de/psdev/devdrawer/utils/Views.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.psdev.devdrawer.utils - -import android.view.LayoutInflater -import android.view.View - -val View.layoutInflater: LayoutInflater - get() = LayoutInflater.from(context) \ 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 deleted file mode 100644 index 7e6f5996..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/CleanupWidgetsWorker.kt +++ /dev/null @@ -1,80 +0,0 @@ -package de.psdev.devdrawer.widgets - -import android.app.Application -import android.appwidget.AppWidgetManager -import android.content.ComponentName -import android.content.Context -import android.graphics.Color -import androidx.hilt.work.HiltWorker -import androidx.work.* -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import de.psdev.devdrawer.appwidget.DDWidgetProvider -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.database.WidgetProfile -import mu.KLogging -import java.util.concurrent.TimeUnit - -@HiltWorker -class CleanupWidgetsWorker @AssistedInject constructor( - @Assisted context: Context, - @Assisted workerParams: WorkerParameters, - private val devDrawerDatabase: DevDrawerDatabase -) : CoroutineWorker(context, workerParams) { - companion object : KLogging() { - @JvmField - val TAG: String = CleanupWidgetsWorker::class.java.simpleName - - fun enableWorker(application: Application) { - val workManager = WorkManager.getInstance(application) - val request = - PeriodicWorkRequestBuilder(30, TimeUnit.MINUTES).build() - workManager.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) - } - } - - override suspend fun doWork(): Result { - logger.warn { "Cleaning orphaned widgets..." } - val widgetDao = devDrawerDatabase.widgetDao() - val widgetManager = AppWidgetManager.getInstance(applicationContext) - - val widgets = widgetDao.findAll() - val databaseWidgetIds = widgets.map { it.id } - val appWidgetIds = - widgetManager.getAppWidgetIds( - ComponentName( - applicationContext, - DDWidgetProvider::class.java - ) - ).toList() - - val deletedWidgets = databaseWidgetIds - appWidgetIds - - if (deletedWidgets.isNotEmpty()) { - logger.warn { "Deleting orphaned widgets from local database: ${widgets.filter { it.id in deletedWidgets }}" } - widgetDao.deleteByIds(deletedWidgets) - } - - val unconfiguredWidgets = appWidgetIds - databaseWidgetIds - if (unconfiguredWidgets.isNotEmpty()) { - val widgetProfileDao = devDrawerDatabase.widgetProfileDao() - val defaultWidgetProfile = - widgetProfileDao.findAll().firstOrNull() ?: WidgetProfile(name = "Default").also { - widgetProfileDao.insert(it) - } - for (unconfiguredWidget in unconfiguredWidgets) { - // Create entries in database - val widget = Widget( - id = unconfiguredWidget, - name = "Unconfigured $unconfiguredWidget", - color = Color.BLACK, - profileId = defaultWidgetProfile.id - ) - widgetDao.insert(widget) - } - } - - return Result.success() - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragment.kt deleted file mode 100644 index 74561804..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragment.kt +++ /dev/null @@ -1,201 +0,0 @@ -package de.psdev.devdrawer.widgets - -import android.app.Activity -import android.appwidget.AppWidgetManager -import android.content.Intent -import android.graphics.Color -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import com.github.dhaval2404.colorpicker.MaterialColorPickerDialog -import com.github.dhaval2404.colorpicker.model.ColorShape -import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.BaseFragment -import de.psdev.devdrawer.R -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.WidgetProfile -import de.psdev.devdrawer.databinding.FragmentWidgetEditBinding -import de.psdev.devdrawer.profiles.WidgetProfilesDetailsLookup -import de.psdev.devdrawer.profiles.WidgetProfilesItemKeyProvider -import de.psdev.devdrawer.profiles.WidgetProfilesListAdapter -import de.psdev.devdrawer.receivers.UpdateReceiver -import de.psdev.devdrawer.utils.awaitSubmit -import de.psdev.devdrawer.utils.receiveClicksFrom -import de.psdev.devdrawer.utils.receiveTextChangesFrom -import de.psdev.devdrawer.utils.sortColorList -import de.psdev.devdrawer.widgets.EditWidgetFragmentViewModel.Selection -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import javax.inject.Inject - -@AndroidEntryPoint -class EditWidgetFragment : BaseFragment() { - - // Dependencies - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - @Inject - lateinit var viewModelViewModelFactory: EditWidgetFragmentViewModel.ViewModelFactory - - val args by navArgs() - - val viewModel: EditWidgetFragmentViewModel by viewModels { - EditWidgetFragmentViewModel.factory(viewModelViewModelFactory, args.widgetId) - } - - var _selectionTracker: SelectionTracker? = null - - override fun createViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentWidgetEditBinding = - FragmentWidgetEditBinding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val adapter = WidgetProfilesListAdapter() - adapter.itemLongClickListener = { widgetProfile -> - findNavController().navigate(EditWidgetFragmentDirections.createProfileAction(widgetProfile.id)) - } - // Setup views - with(binding) { - with(editName) { - setText("Widget ${args.widgetId}") - } - with(btnColor) { - setOnClickListener { - val currentColor = viewModel.savedWidget.value?.color ?: Color.BLACK - MaterialColorPickerDialog - .Builder(requireContext()) - .setTitle(R.string.pick_widget_color) - .setDefaultColor(currentColor) - .setColorShape(ColorShape.SQAURE) - .setColorRes(resources.getIntArray(R.array.widget_colors).sortColorList()) - .setPositiveButton(R.string.ok) - .setNegativeButton(R.string.cancel) - .setColorListener { color, _ -> - setBackgroundColor(color) - viewModel.inputColor.value = color - } - .showBottomSheet(childFragmentManager) - } - } - } - lifecycleScope.launchWhenResumed { - with(binding) { - val widget = checkNotNull(viewModel.savedWidget.filterNotNull().first()) - editName.setText(widget.name) - btnColor.setBackgroundColor(widget.color) - } - } - - binding.btnNewProfile.setOnClickListener { - lifecycleScope.launchWhenResumed { - val widgetProfile = WidgetProfile(name = "Profile for ${viewModel.inputWidgetName.value}") - devDrawerDatabase.widgetProfileDao().insert(widgetProfile) - findNavController().navigate(EditWidgetFragmentDirections.createProfileAction(widgetProfile.id)) - } - } - - binding.recyclerProfiles.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false) - binding.recyclerProfiles.adapter = adapter - val selectionTracker = SelectionTracker.Builder( - "widgetProfile", - binding.recyclerProfiles, - WidgetProfilesItemKeyProvider(adapter), - WidgetProfilesDetailsLookup(binding.recyclerProfiles), - StorageStrategy.createStringStorage() - ).withSelectionPredicate( - SelectionPredicates.createSelectSingleAnything() - ).build().also { - it.onRestoreInstanceState(savedInstanceState) - if (savedInstanceState == null) { - lifecycleScope.launchWhenResumed { - it.select(devDrawerDatabase.widgetDao().findById(args.widgetId)?.profileId.orEmpty()) - } - } - _selectionTracker = it - } - adapter.selectionTracker = selectionTracker - - viewLifecycleScope.launch { - viewModel.inputWidgetName.receiveTextChangesFrom(binding.editName).launchIn(this) - - selectionTracker.addObserver(object : SelectionTracker.SelectionObserver() { - override fun onSelectionChanged() { - super.onSelectionChanged() - val widgetProfile = selectionTracker.selection.asSequence() - .map { selectedKey -> adapter.currentList.firstOrNull { it.id == selectedKey } }.firstOrNull() - if (widgetProfile != null) { - viewModel.inputSelectedProfile.value = Selection.Profile(widgetProfile) - } else { - viewModel.inputSelectedProfile.value = Selection.Nothing - } - } - }) - viewModel.inputSaveTrigger.receiveClicksFrom(binding.btnConfirm).launchIn(this) - viewModel.outputWidgetProfiles.onEach { - adapter.awaitSubmit(it) - binding.txtNoProfiles.isVisible = it.isEmpty() - }.launchIn(this) - viewModel.outputFormCompleted.onEach { completed -> - if (completed) { - with(binding.btnConfirm) { - isEnabled = true - setText(R.string.save) - } - } else { - with(binding.btnConfirm) { - isEnabled = false - text = "Select profile" - } - } - }.launchIn(this) - viewModel.outputCloseTrigger.onEach { widget -> - val resultValue = Intent().apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget.id) - } - requireActivity().setResult(Activity.RESULT_OK, resultValue) - // Will either close the fragment or finish the activity when it's the last activity - if (!findNavController().popBackStack()) { - requireActivity().finish() - } - UpdateReceiver.send(requireContext()) - }.launchIn(this) - } - } - - override fun onResume() { - super.onResume() - updateToolbarTitle(R.string.edit_widget) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - _selectionTracker?.onSaveInstanceState(outState) - } - - override fun onDestroyView() { - binding.recyclerProfiles.adapter = null - super.onDestroyView() - } - - // TODO Default name: Widget - // TODO After losing focus of text input update name in viewState - -} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt deleted file mode 100644 index df82a4a4..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt +++ /dev/null @@ -1,98 +0,0 @@ -package de.psdev.devdrawer.widgets - -import android.graphics.Color -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider.Factory -import androidx.lifecycle.viewModelScope -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.database.WidgetProfile -import de.psdev.devdrawer.utils.simpleFactory -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import mu.KLogging - -class EditWidgetFragmentViewModel @AssistedInject constructor( - private val database: DevDrawerDatabase, - @Assisted private val widgetId: Int -) : ViewModel() { - - companion object : KLogging() { - fun factory( - viewModelFactory: ViewModelFactory, - widgetId: Int - ): Factory = simpleFactory { - viewModelFactory.create(widgetId) - } - } - - // Inputs - val inputWidgetName = MutableStateFlow("") - val inputColor = MutableStateFlow(Color.BLACK) - val inputSelectedProfile = MutableStateFlow(Selection.Nothing) - val inputSaveTrigger = MutableSharedFlow(1) - - // Outputs - val outputWidgetProfiles - get() = database.widgetProfileDao().findAllFlow() - - val outputFormCompleted = combine(inputWidgetName, inputSelectedProfile) { name, selection -> - name.isNotBlank() && selection is Selection.Profile - } - - // TODO add sealed class for success / cancel - val outputCloseTrigger = MutableSharedFlow(1) - - val savedWidget: MutableStateFlow = MutableStateFlow(null) - - init { - viewModelScope.launch { - savedWidget.value = database.widgetDao().findById(widgetId)?.also { widget -> - inputWidgetName.value = widget.name - inputColor.value = widget.color - } - } - combine(inputWidgetName, inputColor) { name, color -> - logger.info { "Update savedWidget: $name, $color" } - savedWidget.value?.let { widget -> - widget.name = name - widget.color = color - } - }.launchIn(viewModelScope) - inputSaveTrigger.asSharedFlow().flatMapLatest { - combine( - inputWidgetName, - inputColor, - inputSelectedProfile.filterIsInstance() - ) { name, color, selection -> - savedWidget.value?.copy( - name = name, - color = color, - profileId = selection.profile.id - ) ?: Widget( - id = widgetId, - name = name, - color = color, - profileId = selection.profile.id - ) - } - }.onEach { widget -> - database.widgetDao().insertOrUpdate(widget) - savedWidget.value = widget - outputCloseTrigger.emit(widget) - }.launchIn(viewModelScope) - } - - sealed class Selection { - object Nothing : Selection() - data class Profile(val profile: WidgetProfile) : Selection() - } - - @AssistedFactory - interface ViewModelFactory { - fun create(widgetId: Int): EditWidgetFragmentViewModel - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt new file mode 100644 index 00000000..bc744153 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt @@ -0,0 +1,11 @@ +package de.psdev.devdrawer.widgets + +import de.psdev.devdrawer.database.Widget +import kotlinx.coroutines.flow.Flow + +interface IWidgetRepository { + fun widgetFlow(widgetId: Int): Flow + suspend fun update(widget: Widget) + suspend fun delete(widget: Widget) + suspend fun findWidgetsForProfile(profileId: String): List +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt deleted file mode 100644 index 278e2157..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt +++ /dev/null @@ -1,78 +0,0 @@ -package de.psdev.devdrawer.widgets - -import android.app.Activity -import android.appwidget.AppWidgetManager -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.navigation.findNavController -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupWithNavController -import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.BaseActivity -import de.psdev.devdrawer.R -import de.psdev.devdrawer.analytics.Events -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.databinding.ActivityWidgetConfigBinding -import mu.KLogging -import javax.inject.Inject - -@AndroidEntryPoint -class WidgetConfigActivity : BaseActivity() { - - companion object : KLogging() { - fun createStartIntent(context: Context, appWidgetId: Int): Intent = - Intent(context, WidgetConfigActivity::class.java).apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) - } - } - - // Dependencies - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - private lateinit var binding: ActivityWidgetConfigBinding - - // ========================================================================================================================== - // Activity Lifecycle - // ========================================================================================================================== - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - // Set the result to CANCELED. This will cause the widget host to cancel - // out of the widget placement if they press the back button. - setResult(RESULT_CANCELED) - - val widgetId = getWidgetId() - if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - finish() - return - } - if (intent.getBooleanExtra("from_widget", false)) { - trackingService.trackAction(Events.EVENT_WIDGET_OPEN_SETTINGS) - } - - binding = ActivityWidgetConfigBinding.inflate(layoutInflater) - setContentView(binding.root) - setSupportActionBar(binding.toolbar) - - val navController = findNavController(R.id.nav_host_fragment) - navController.setGraph(R.navigation.nav_config_widget, EditWidgetFragmentArgs(widgetId).toBundle()) - val appBarConfiguration = AppBarConfiguration(navController.graph) - binding.toolbar.setupWithNavController(navController, appBarConfiguration) - } - - // ========================================================================================================================== - // Public API - // ========================================================================================================================== - - override fun onBackPressed() { - setResult(Activity.RESULT_CANCELED, null) - super.onBackPressed() - } - - private fun getWidgetId(): Int = - intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) - ?: AppWidgetManager.INVALID_APPWIDGET_ID - -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetListFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetListFragment.kt deleted file mode 100644 index e4aafdab..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetListFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -package de.psdev.devdrawer.widgets - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.content.ComponentName -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.RequiresApi -import androidx.core.content.getSystemService -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.BaseFragment -import de.psdev.devdrawer.R -import de.psdev.devdrawer.appwidget.DDWidgetProvider -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.databinding.FragmentWidgetListBinding -import de.psdev.devdrawer.utils.Constants -import de.psdev.devdrawer.utils.awaitSubmit -import de.psdev.devdrawer.utils.supportsVersion -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import mu.KLogging -import javax.inject.Inject - - -@AndroidEntryPoint -class WidgetListFragment : BaseFragment() { - - companion object : KLogging() - - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - override fun createViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentWidgetListBinding = FragmentWidgetListBinding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val clickListener: (Widget) -> Unit = { widget -> - findNavController().navigate(WidgetListFragmentDirections.editWidget(widget.id)) - } - val listAdapter = WidgetsListAdapter(clickListener) - with(binding) { - recyclerWidgets.adapter = listAdapter - } - devDrawerDatabase.widgetDao().findAllFlow().onEach { - listAdapter.awaitSubmit(it) - binding.containerNoWidgets.isVisible = it.isEmpty() - supportsVersion(Build.VERSION_CODES.O) { - with(binding.btnAddWidget) { - isVisible = true - setOnClickListener { - requestAppWidgetPinning() - } - } - } - }.launchIn(lifecycleScope) - } - - override fun onResume() { - super.onResume() - updateToolbarTitle(R.string.widgets) - } - - override fun onDestroyView() { - binding.recyclerWidgets.adapter = null - super.onDestroyView() - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun requestAppWidgetPinning() { - val activity = requireActivity() - val appWidgetManager: AppWidgetManager = activity.getSystemService() ?: return - val widgetProvider = ComponentName(activity, DDWidgetProvider::class.java) - if (appWidgetManager.isRequestPinAppWidgetSupported) { - val pinnedWidgetCallbackIntent = Intent(activity, DDWidgetProvider::class.java).apply { - action = Constants.ACTION_WIDGET_PINNED - } - val successCallback = PendingIntent.getBroadcast( - activity, - 1, - pinnedWidgetCallbackIntent, - PendingIntent.FLAG_UPDATE_CURRENT - ) - val bundle = bundleOf() - appWidgetManager.requestPinAppWidget(widgetProvider, bundle, successCallback) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt new file mode 100644 index 00000000..04b77969 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt @@ -0,0 +1,31 @@ +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 +) : IWidgetRepository { + + override fun widgetFlow(widgetId: Int) = devDrawerDatabase.widgetDao().widgetWithIdObservable(widgetId) + + override suspend fun update(widget: Widget) { + devDrawerDatabase.widgetDao().update(widget) + UpdateReceiver.send(application) + } + + override suspend fun delete(widget: Widget) { + devDrawerDatabase.widgetDao().delete(widget) + UpdateReceiver.send(application) + } + + override suspend fun findWidgetsForProfile(profileId: String): List = + devDrawerDatabase.widgetDao().findAllByProfileId(profileId) + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetsListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetsListAdapter.kt deleted file mode 100644 index 50480379..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetsListAdapter.kt +++ /dev/null @@ -1,46 +0,0 @@ -package de.psdev.devdrawer.widgets - -import android.view.ViewGroup -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.databinding.ListItemWidgetBinding -import de.psdev.devdrawer.utils.layoutInflater -import mu.KLogging - -class WidgetsListAdapter( - private val clickListener: (Widget) -> Unit -): ListAdapter(Widget.DIFF_CALLBACK) { - - companion object: KLogging() - - // ========================================================================================================================== - // RecyclerView.Adapter - // ========================================================================================================================== - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WidgetsListViewHolder = - WidgetsListViewHolder( - ListItemWidgetBinding.inflate(parent.layoutInflater, parent, false), - clickListener - ) - - override fun onBindViewHolder(holder: WidgetsListViewHolder, position: Int) { - holder.bindTo(getItem(position)) - } - - // ========================================================================================================================== - // WidgetsListViewHolder - // ========================================================================================================================== - - class WidgetsListViewHolder( - private val binding: ListItemWidgetBinding, - private val clickListener: (Widget) -> Unit - ): RecyclerView.ViewHolder(binding.root) { - - fun bindTo(widget: Widget) { - binding.txtName.text = widget.name - itemView.setOnClickListener { clickListener(widget) } - } - } -} - diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt new file mode 100644 index 00000000..c3085dd6 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt @@ -0,0 +1,60 @@ +package de.psdev.devdrawer.widgets + +import android.content.res.Configuration +import android.graphics.Color +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun WidgetCard( + widget: Widget, + onWidgetClick: (Widget) -> Unit = {} +) { + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(8.dp) + .clickable { onWidgetClick(widget) } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(16.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + text = widget.name + ) + Text( + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + text = stringResource(id = R.string.widget_id_template, widget.id) + ) + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetCard() { + DevDrawerTheme { + WidgetCard(widget = Widget(1, "Test Widget", Color.BLACK, "")) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt new file mode 100644 index 00000000..9495e6d4 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt @@ -0,0 +1,101 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun ColorGrid( + initialColor: Int, + onColorClicked: (Int) -> Unit = {} +) { + var selectedColor by remember { mutableStateOf(initialColor) } + val colors = listOf( + android.graphics.Color.BLACK, + android.graphics.Color.DKGRAY, + android.graphics.Color.GRAY, + android.graphics.Color.LTGRAY, + android.graphics.Color.WHITE, + android.graphics.Color.RED, + android.graphics.Color.GREEN, + android.graphics.Color.BLUE, + android.graphics.Color.YELLOW, + android.graphics.Color.CYAN, + android.graphics.Color.MAGENTA + ) + LazyVerticalGrid( + modifier = Modifier.wrapContentHeight(), + columns = GridCells.Adaptive(minSize = 64.dp), + contentPadding = PaddingValues(8.dp) + ) { + items(colors) { color -> + val isSelectedColor = color == selectedColor + ColorBox( + isSelectedColor = isSelectedColor, + color = color + ) { + selectedColor = it + onColorClicked(it) + } + } + } +} + +@Composable +fun ColorBox( + modifier: Modifier = Modifier, + isSelectedColor: Boolean, + color: Int, + onColorClicked: (Int) -> Unit +) { + val cornerSize by animateDpAsState( + targetValue = if (isSelectedColor) 8.dp else 0.dp + ) + val borderWidth by animateDpAsState( + targetValue = if (isSelectedColor) 2.dp else 1.dp + ) + val borderColor by animateColorAsState( + targetValue = Color(if (isSelectedColor) android.graphics.Color.WHITE else android.graphics.Color.BLACK) + ) + val shape = RoundedCornerShape( + size = cornerSize + ) + Box(modifier = modifier + .padding(8.dp) + .requiredSize(48.dp) + .clip(shape) + .border( + width = borderWidth, + color = borderColor, + shape = shape + ) + .background(Color(color), shape = shape) + .clickable { + onColorClicked(color) + } + ) +} + +@Preview +@Composable +fun Preview_ColorGrid() { + DevDrawerTheme { + ColorGrid( + initialColor = android.graphics.Color.BLACK + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt new file mode 100644 index 00000000..5be8dd6c --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt @@ -0,0 +1,64 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import de.psdev.devdrawer.R +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun ColorSelectionDialog( + initialColor: Int, + onColorSelectionChanged: (Int) -> Unit = {}, + onColorSelected: (Int) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + var selectedColor by remember { mutableStateOf(initialColor) } + AlertDialog( + onDismissRequest = { }, + title = { + Text(text = "Select color") + }, + text = { + ColorGrid( + initialColor = initialColor, + onColorClicked = { + selectedColor = it + onColorSelectionChanged(it) + } + ) + }, + dismissButton = { + TextButton(onClick = { + onDismiss() + }) { + Text(stringResource(id = R.string.cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + onColorSelected(selectedColor) + }) { + Text(stringResource(id = R.string.apply)) + } + } + ) +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_ColorSelectionDialog() { + DevDrawerTheme { + Column { + ColorSelectionDialog( + initialColor = android.graphics.Color.BLACK + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt new file mode 100644 index 00000000..cf33cae7 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt @@ -0,0 +1,294 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +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.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.psdev.devdrawer.AppBarActionsProvider +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.ui.theme.DevDrawerTheme +import mu.KotlinLogging +import java.util.UUID + +private val logger = KotlinLogging.logger { } + +@Composable +fun WidgetEditorScreen( + id: Int, + viewModel: WidgetEditorViewModel = hiltViewModel { + it.create( + id + ) + }, + menuCallback: AppBarActionsProvider, + onBack: () -> Unit, + onEditWidgetProfile: (WidgetProfile) -> Unit +) { + WidgetEditorScreen( + viewModel = viewModel, + menuCallback = menuCallback, + onBack = onBack, + onEditWidgetProfile = onEditWidgetProfile, + onChangesSaved = {} + ) +} + +@Composable +fun WidgetEditorScreen( + viewModel: WidgetEditorViewModel, + menuCallback: AppBarActionsProvider, + onBack: () -> Unit, + onEditWidgetProfile: (WidgetProfile) -> Unit, + onChangesSaved: (Widget) -> Unit +) { + val viewState by viewModel.state.collectAsStateWithLifecycle() + + val persistedWidget = viewState.persistedWidget + ProvideMenu(menuCallback, persistedWidget) { + if (persistedWidget != null) { + IconButton(onClick = { + viewModel.deleteWidget(persistedWidget) + onBack() + }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null + ) + } + } + } + WidgetEditor( + viewState = viewState, + onNameChange = viewModel::onNameChanged, + onColorSelected = { color -> + viewModel.onWidgetColorChanged(color) + }, + onEditWidgetProfile = onEditWidgetProfile, + onWidgetProfileSelected = viewModel::onWidgetProfileSelected, + onSaveChangesClick = { + viewModel.saveChanges() + persistedWidget?.let(onChangesSaved) + } + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun WidgetEditor( + modifier: Modifier = Modifier, + viewState: WidgetEditorViewState, + onNameChange: (String) -> Unit = {}, + onColorSelected: (Int) -> Unit = {}, + onEditWidgetProfile: (WidgetProfile) -> Unit = {}, + onWidgetProfileSelected: (WidgetProfile) -> Unit = {}, + onSaveChangesClick: () -> Unit = {} +) { + val widget = viewState.editableWidget + if (widget == null) { + 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) { + Row( + modifier = Modifier + .wrapContentHeight() + .padding(8.dp), + 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 -> + 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) + ) + ) + } +} 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..f75f6400 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt @@ -0,0 +1,87 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.analytics.Events +import de.psdev.devdrawer.analytics.TrackingService +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.profiles.IWidgetProfileRepository +import de.psdev.devdrawer.widgets.IWidgetRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = WidgetEditorViewModel.Factory::class) +class WidgetEditorViewModel @AssistedInject constructor( + @Assisted private val widgetId: Int, + private val widgetRepository: IWidgetRepository, + private val widgetProfileRepository: IWidgetProfileRepository, + private val trackingService: TrackingService +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(widgetId: Int): WidgetEditorViewModel + } + + private val editableWidgetState: MutableStateFlow = MutableStateFlow(null) + + init { + // Initialise the editable copy from the persisted widget on first load. + // Done here rather than inside the combine transform to avoid side effects in a pure combiner. + viewModelScope.launch { + val initial = widgetRepository.widgetFlow(widgetId).filterNotNull().first() + editableWidgetState.value = initial + } + } + + val state: StateFlow = combine( + widgetRepository.widgetFlow(widgetId), + widgetProfileRepository.widgetProfilesFlow(), + editableWidgetState + ) { persistedWidget, widgetProfiles, editableWidget -> + WidgetEditorViewState( + persistedWidget = persistedWidget, + widgetProfiles = widgetProfiles, + editableWidget = editableWidget ?: persistedWidget + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetEditorViewState.Empty) + + fun onNameChanged(newName: String) { + editableWidgetState.update { it?.copy(name = newName) } + } + + fun onWidgetColorChanged(color: Int) { + editableWidgetState.update { it?.copy(color = color) } + } + + fun onWidgetProfileSelected(widgetProfile: WidgetProfile) { + editableWidgetState.update { it?.copy(profileId = widgetProfile.id) } + } + + fun saveChanges() { + editableWidgetState.value?.let { + viewModelScope.launch { + widgetRepository.update(it) + } + } + } + + fun deleteWidget(widget: Widget) { + viewModelScope.launch { + widgetRepository.delete(widget) + trackingService.trackAction(Events.WIDGET_DELETED) + } + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt new file mode 100644 index 00000000..0f48e329 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt @@ -0,0 +1,16 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import androidx.compose.runtime.Immutable +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile + +@Immutable +data class WidgetEditorViewState( + val persistedWidget: Widget? = null, + val editableWidget: Widget? = null, + val widgetProfiles: List = emptyList() +) { + companion object { + val Empty = WidgetEditorViewState() + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt new file mode 100644 index 00000000..363fd38a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt @@ -0,0 +1,43 @@ +package de.psdev.devdrawer.widgets.ui.list + +import android.content.res.Configuration +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import de.psdev.devdrawer.widgets.WidgetCard + +@Composable +fun WidgetList( + widgets: List, + modifier: Modifier = Modifier, + onWidgetClick: (Widget) -> Unit = {}, + contentPadding: PaddingValues = PaddingValues(0.dp) +) { + LazyColumn( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight(), + contentPadding = contentPadding, + ) { + items(widgets, key = { it.id }) { widget -> + WidgetCard(widget = widget, onWidgetClick = onWidgetClick) + } + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetList() { + DevDrawerTheme { + WidgetList(widgets = testWidgets()) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt new file mode 100644 index 00000000..6c58fc49 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt @@ -0,0 +1,198 @@ +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.getValue +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.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import java.util.UUID + +@Composable +fun WidgetListScreen( + widgetListScreenViewModel: WidgetListScreenViewModel = hiltViewModel(), + onWidgetClick: (Widget) -> Unit +) { + val context = LocalContext.current + val state by widgetListScreenViewModel.state.collectAsStateWithLifecycle() + WidgetListScreen( + state = state, + onWidgetClick = onWidgetClick, + 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..5f9b778a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt @@ -0,0 +1,53 @@ +package de.psdev.devdrawer.widgets.ui.list + +import android.annotation.SuppressLint +import android.app.Application +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 androidx.lifecycle.viewModelScope +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 kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class WidgetListScreenViewModel @Inject constructor( + private val application: Application, + database: DevDrawerDatabase +) : ViewModel() { + + val state = database.widgetDao().findAllFlow() + .map { widgets -> + val appWidgetManager: AppWidgetManager? = application.getSystemService() + WidgetListScreenState.Loaded( + widgets = widgets, + isRequestPinAppWidgetSupported = appWidgetManager?.isRequestPinAppWidgetSupported == true + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetListScreenState.Loading) + + @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) + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index df148b41..00000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_widget_config.xml b/app/src/main/res/layout/activity_widget_config.xml deleted file mode 100644 index 70c55fbe..00000000 --- a/app/src/main/res/layout/activity_widget_config.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/add_package_filter_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/add_package_filter_bottom_sheet_dialog_fragment.xml deleted file mode 100644 index 27af9345..00000000 --- a/app/src/main/res/layout/add_package_filter_bottom_sheet_dialog_fragment.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/app_signature_chooser_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/app_signature_chooser_bottom_sheet_dialog_fragment.xml deleted file mode 100644 index 74de7cbe..00000000 --- a/app/src/main/res/layout/app_signature_chooser_bottom_sheet_dialog_fragment.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dropdown_list_item.xml b/app/src/main/res/layout/dropdown_list_item.xml deleted file mode 100644 index 5e7431f2..00000000 --- a/app/src/main/res/layout/dropdown_list_item.xml +++ /dev/null @@ -1,14 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/filter_preview_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/filter_preview_bottom_sheet_dialog_fragment.xml deleted file mode 100644 index bac62e23..00000000 --- a/app/src/main/res/layout/filter_preview_bottom_sheet_dialog_fragment.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml deleted file mode 100644 index 241826b7..00000000 --- a/app/src/main/res/layout/fragment_about.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_widget_edit.xml b/app/src/main/res/layout/fragment_widget_edit.xml deleted file mode 100644 index 1eb31de5..00000000 --- a/app/src/main/res/layout/fragment_widget_edit.xml +++ /dev/null @@ -1,115 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_widget_list.xml b/app/src/main/res/layout/fragment_widget_list.xml deleted file mode 100644 index 2e0c0e47..00000000 --- a/app/src/main/res/layout/fragment_widget_list.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_widget_profile_edit.xml b/app/src/main/res/layout/fragment_widget_profile_edit.xml deleted file mode 100644 index f767f9e0..00000000 --- a/app/src/main/res/layout/fragment_widget_profile_edit.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_widget_profile_list.xml b/app/src/main/res/layout/fragment_widget_profile_list.xml deleted file mode 100644 index 437e06d8..00000000 --- a/app/src/main/res/layout/fragment_widget_profile_list.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/list_item_app.xml b/app/src/main/res/layout/list_item_app.xml deleted file mode 100644 index 907d7528..00000000 --- a/app/src/main/res/layout/list_item_app.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - \ No newline at end of file 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/layout/list_item_widget_profile.xml b/app/src/main/res/layout/list_item_widget_profile.xml deleted file mode 100644 index e09f9716..00000000 --- a/app/src/main/res/layout/list_item_widget_profile.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_bottom_nav_main.xml b/app/src/main/res/menu/menu_bottom_nav_main.xml deleted file mode 100644 index ecade8ec..00000000 --- a/app/src/main/res/menu/menu_bottom_nav_main.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_fragment_widget_profile_edit.xml b/app/src/main/res/menu/menu_fragment_widget_profile_edit.xml deleted file mode 100644 index 1c3f3635..00000000 --- a/app/src/main/res/menu/menu_fragment_widget_profile_edit.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_profiles_list.xml b/app/src/main/res/menu/menu_profiles_list.xml deleted file mode 100644 index 68377529..00000000 --- a/app/src/main/res/menu/menu_profiles_list.xml +++ /dev/null @@ -1,24 +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 deleted file mode 100644 index cfda4f96..00000000 --- a/app/src/main/res/navigation/nav_config_widget.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml deleted file mode 100644 index 34e14252..00000000 --- a/app/src/main/res/navigation/nav_main.xml +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 00000000..eb1ca3ec --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,27 @@ + + + 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-night/colors.xml b/app/src/main/res/values-night/colors.xml index 852cc8f0..3567b99f 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,13 +1,13 @@ - #8bc34a - #5a9216 - #FFF + #FFC107 + #FFA000 + #000000 - #ff9800 - #c66900 - #FFF + #FF5722 + #E64A19 + #FFFFFF #FFFFFF #000000 @@ -18,6 +18,6 @@ #FFF #CBFFFFFF - #4D8BC34A + #4DFFC107 #FFF - \ No newline at end of file + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 830b90d6..39cd2bc7 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,13 +1,13 @@ - #8bc34a - #5a9216 - #FFF + #FFC107 + #FFA000 + #000000 - #ff9800 - #c66900 - #FFF + #FF5722 + #E64A19 + #FFFFFF #FFFFFF #000000 @@ -18,7 +18,7 @@ #000 #222 - #4D8BC34A + #4DFFC107 #000 @@ -44,4 +44,4 @@ @color/teal_400 @color/yellow_400 - \ 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..52890ff3 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 @@ -24,6 +24,12 @@ @string/sort_order_package_name + theme + SYSTEM + + dynamic_color + false + feature_analytics_opted_in @@ -69,5 +75,42 @@ Cannot delete profile, still being used by widgets No Yes + ComposeActivity + + + ID: %1$d + + + Create new profile + + + Loading… + Last modified + + + General + User Interface + Analytics + About + + Show activity choice on launch + Widget Sorting Options + Theme + + System default + Light + Dark + + Material You colors + Use colors generated from your wallpaper + Opt-in to analytics + Clear + Delete profile? + Delete filter? + No apps available or all already added as filter + More + Discard changes? + You have unsaved changes. Do you want to discard them? + Discard 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 @@ + + + +