diff --git a/.travis.yml b/.travis.yml index f2ddd737..6329c873 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,6 @@ language: android +jdk: + - openjdk11 android: components: - tools diff --git a/app/build.gradle b/app/build.gradle index 4e4e1600..5ef414e8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,13 +1,13 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' -apply plugin: "androidx.navigation.safeargs.kotlin" +apply plugin: "androidx.navigation.safeargs" 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' +apply plugin: 'com.dicedmelon.gradle.jacoco-android' android { compileSdkVersion Config.compile_sdk @@ -28,10 +28,15 @@ android { // Version info buildConfigField 'String', 'GIT_SHA', "\"${project.ext.gitHash}\"" + vectorDrawables { + useSupportLibrary true + } + javaCompileOptions.annotationProcessorOptions.arguments['room.schemaLocation'] = rootProject.file('schemas').toString() } buildFeatures { viewBinding true + compose true } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -45,8 +50,12 @@ android { "-Xopt-in=kotlin.ExperimentalStdlibApi", "-Xopt-in=kotlin.time.ExperimentalTime", "-Xopt-in=kotlinx.coroutines.FlowPreview", - "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi" + "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi", + "-Xopt-in=androidx.compose.animation.ExperimentalAnimationApi", + "-Xopt-in=androidx.compose.material.ExperimentalMaterialApi" ] + useIR = true } testOptions { unitTests { @@ -105,6 +114,9 @@ android { exclude '**/NOTICE.txt' exclude '**/*.gwt.xml' } + composeOptions { + kotlinCompilerExtensionVersion Versions.androidXCompose + } } dependencies { @@ -133,13 +145,17 @@ dependencies { implementation Libs.androidx_browser implementation Libs.androidx_constraint_layout implementation Libs.androidx_core + implementation "androidx.core:core-splashscreen:1.0.0-alpha01" implementation Libs.androidx_fragment implementation Libs.androidx_hilt_work implementation Libs.androidx_lifecycle_viewmodel + implementation Libs.androidx_lifecycle_livedata implementation Libs.androidx_lifecycle_java8 + implementation Libs.androidx_lifecycle_runtime implementation Libs.androidx_lifecycle_process implementation Libs.androidx_navigation_fragment implementation Libs.androidx_navigation_ui + implementation "androidx.navigation:navigation-compose:$Versions.androidXNavigation" implementation Libs.androidx_preference implementation Libs.androidx_recyclerview implementation Libs.androidx_recyclerview_selection @@ -147,6 +163,20 @@ dependencies { implementation Libs.androidx_room_ktx implementation Libs.androidx_work_runtime implementation Libs.androidx_work_gcm + implementation 'com.google.android.material:material:1.4.0' + implementation 'androidx.activity:activity-compose:1.3.0-rc01' + implementation "androidx.compose.ui:ui:$Versions.androidXCompose" + implementation "androidx.compose.foundation:foundation:$Versions.androidXCompose" + implementation "androidx.compose.material:material:$Versions.androidXCompose" + implementation "androidx.compose.material:material-icons-core:$Versions.androidXCompose" + implementation "androidx.compose.material:material-icons-extended:$Versions.androidXCompose" +// implementation "androidx.compose.ui:ui-tooling:$Versions.androidXCompose" + implementation "androidx.compose.ui:ui-tooling:1.0.0-beta09" + implementation "com.google.accompanist:accompanist-insets:0.13.0" + + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07' + implementation 'androidx.hilt:hilt-navigation-compose:1.0.0-alpha03' + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$Versions.androidXCompose" kapt Libs.androidx_room_compiler kapt Libs.androidx_hilt_compiler @@ -182,8 +212,8 @@ dependencies { implementation Libs.kotlinCoroutinesAndroid // LeakCanary - debugImplementation Libs.leakCanary - implementation Libs.leakCanaryPlumberAndroid +// debugImplementation Libs.leakCanary +// implementation Libs.leakCanaryPlumberAndroid // Logging implementation Libs.slf4jAndroidLogger @@ -200,6 +230,10 @@ kapt { correctErrorTypes true } +jacoco { + toolVersion = "0.8.7" +} + play { def serviceAccountFileName = "google-play-api.json" if (rootProject.file(serviceAccountFileName).exists()) { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78d9bcdf..f6340798 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,10 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme.NoActionBar"> - + @@ -30,6 +33,7 @@ android:name=".appwidget.ClickHandlingActivity" android:allowTaskReparenting="false" android:excludeFromRecents="true" + android:exported="true" android:noHistory="true" android:taskAffinity="" android:theme="@android:style/Theme.NoDisplay"> @@ -46,13 +50,17 @@ android:taskAffinity="" android:theme="@style/AppTheme.Dialog.NoActionBar" /> - + - + @@ -65,21 +73,19 @@ android:name=".receivers.UpdateReceiver" android:exported="false" /> + + - - + android:name="androidx.work.impl.WorkManagerInitializer" + android:authorities="${applicationId}.workmanager-init" + tools:node="remove" /> : Fragment() { +open class BaseFragment : Fragment() { @Inject lateinit var trackingService: TrackingService - - private var _binding: T? = null - // This property is only valid between onCreateView and onDestroyView. - protected val binding get() = _binding!! - protected var toolbarTitle: CharSequence get() = requireActivity().title set(value) { requireActivity().title = value } - - final override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = createViewBinding(inflater, container, savedInstanceState).also { viewBinding -> - _binding = viewBinding - }.root - - protected abstract fun createViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): T - - @CallSuper - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } + val Fragment.viewLifecycleScope: LifecycleCoroutineScope + get() = viewLifecycleOwner.lifecycleScope protected fun updateToolbarTitle(@StringRes resId: Int) { requireActivity().setTitle(resId) trackingService.trackScreen(this::class.java, getString(resId)) } - - val Fragment.viewLifecycleScope: LifecycleCoroutineScope - get() = viewLifecycleOwner.lifecycleScope - -} +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt index 06ce493d..86e192d1 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerApplication.kt @@ -29,7 +29,13 @@ class DevDrawerApplication: Application(), Configuration.Provider { registerAppInstallationReceiver() setupWorkers() }.let { - logger.warn("{} version {} ({}) took {}ms to init", this::class.java.simpleName, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, it) + logger.warn( + "{} version {} ({}) took {}ms to init", + this::class.java.simpleName, + BuildConfig.VERSION_NAME, + BuildConfig.VERSION_CODE, + it + ) } } @@ -37,12 +43,9 @@ class DevDrawerApplication: Application(), Configuration.Provider { // Configuration.Provider // ========================================================================================================================== - override fun getWorkManagerConfiguration(): Configuration { - logger.warn { "getWorkManagerConfiguration" } - return Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() - } + override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() // ========================================================================================================================== // Private API diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerScreen.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerScreen.kt new file mode 100644 index 00000000..9e26bd4a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerScreen.kt @@ -0,0 +1,56 @@ +package de.psdev.devdrawer + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Grid3x3 +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Widgets +import androidx.compose.ui.graphics.vector.ImageVector +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile + +sealed class DevDrawerScreen( + val route: String +) + +sealed class TopLevelScreen(route: String): DevDrawerScreen(route) { + abstract val icon: ImageVector + @get:StringRes abstract val label: Int +} + +object Widgets: TopLevelScreen( + route = "widgets" +) { + override val icon: ImageVector = Icons.Filled.Widgets + override val label: Int = R.string.widgets +} + +object Profiles: TopLevelScreen( + route = "profiles" +) { + override val icon: ImageVector = Icons.Filled.Grid3x3 + override val label: Int = R.string.profiles +} + +object Settings: TopLevelScreen( + route = "settings" +) { + override val icon: ImageVector = Icons.Filled.Settings + override val label: Int = R.string.settings +} + +object AppInfo: TopLevelScreen( + route = "info" +) { + override val icon: ImageVector = Icons.Filled.Info + override val label: Int = R.string.app_info +} + +data class WidgetEditorDestination( + val widget: Widget +): DevDrawerScreen(route = "widgets/${widget.id}") + +data class ProfileEditorDestination( + val widgetProfile: WidgetProfile +): DevDrawerScreen(route = "profiles/${widgetProfile.id}") diff --git a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt index 5b118f56..28d15c45 100644 --- a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt @@ -1,22 +1,43 @@ package de.psdev.devdrawer import android.os.Bundle +import android.view.View +import android.view.ViewTreeObserver +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope -import androidx.navigation.findNavController -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupWithNavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.* +import com.google.accompanist.insets.ProvideWindowInsets +import com.google.accompanist.insets.navigationBarsWithImePadding +import com.google.accompanist.insets.statusBarsPadding import dagger.hilt.android.AndroidEntryPoint +import de.psdev.devdrawer.DevDrawerScreen.* import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.databinding.ActivityMainBinding +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.ui.theme.DevDrawerTheme +import de.psdev.devdrawer.widgets.ui.editor.WidgetEditor +import de.psdev.devdrawer.widgets.ui.list.WidgetListScreen +import kotlinx.coroutines.delay import mu.KLogging import javax.inject.Inject @AndroidEntryPoint -class MainActivity : BaseActivity() { +class MainActivity: BaseActivity() { - companion object : KLogging() - - private lateinit var binding: ActivityMainBinding + companion object: KLogging() @Inject lateinit var devDrawerDatabase: DevDrawerDatabase @@ -27,24 +48,140 @@ class MainActivity : BaseActivity() { public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - setSupportActionBar(binding.toolbar) - - val navController = findNavController(R.id.nav_host_fragment) - val appBarConfiguration = AppBarConfiguration.Builder( - R.id.widget_list_fragment, - R.id.profiles_list_fragment, - R.id.settings_fragment, - R.id.about_fragment - ).build() - binding.toolbar.setupWithNavController(navController, appBarConfiguration) - binding.navbar.setupWithNavController(navController) + WindowCompat.setDecorFitsSystemWindows(window, false) + setContent { + DevDrawerApp() + } + val start = System.currentTimeMillis() + val content: View = findViewById(android.R.id.content) + content.viewTreeObserver.addOnPreDrawListener( + object: ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + // Check if the initial data is ready. + return if (System.currentTimeMillis() - start > 500L) { + // The content is ready; start drawing. + content.viewTreeObserver.removeOnPreDrawListener(this) + true + } else { + // The content is not ready; suspend. + false + } + } + } + ) + lifecycleScope.launchWhenCreated { + delay(600L) + content.postInvalidate() + } lifecycleScope.launchWhenResumed { trackingService.checkOptIn(this@MainActivity) } } +} + +@Composable +fun DevDrawerApp() { + DevDrawerTheme { + Surface(color = MaterialTheme.colors.background) { + ProvideWindowInsets { + val navController = rememberNavController() + Scaffold( + topBar = { + TopAppBar(modifier = Modifier.statusBarsPadding()) { + Text(text = stringResource(id = R.string.app_name)) + } + }, + content = { + DevDrawerHost(navController, modifier = Modifier.padding(it)) + }, + bottomBar = { + BottomNavigation(modifier = Modifier.navigationBarsWithImePadding()) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val topLevel = listOf( + Widgets, + Profiles, + Settings, + AppInfo + ) + topLevel.forEach { screen -> + BottomNavigationItem( + icon = { Icon(screen.icon, contentDescription = null) }, + label = { Text(stringResource(screen.label)) }, + selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, + onClick = { + navController.navigate(screen.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + } + ) + } + } + } + ) + } + } + } +} + +@Composable +fun DevDrawerHost(navController: NavHostController, modifier: Modifier = Modifier) { + NavHost( + navController = navController, + startDestination = Widgets.route, + modifier = modifier + ) { + composable(Widgets.route) { + WidgetListScreen( + navController = navController, + onWidgetClick = { widget -> + navController.navigate(WidgetEditorDestination(widget).route) + } + ) + } + composable(Widgets.route + "/{widgetId}", arguments = listOf( + navArgument("widgetId") { + type = NavType.IntType + } + )) { + WidgetEditor() + } + composable(Profiles.route) { + WidgetProfilesScreen( + editProfile = { navController.navigate(ProfileEditorDestination(it).route) } + ) + } + composable("profiles/{profileId}", arguments = listOf( + navArgument("profileId") { + type = NavType.StringType + } + )) { + WidgetProfileEditor() + } + composable(Settings.route) { + SettingsScreen() + } + composable(AppInfo.route) { + + } + } + +} +@Preview +@Composable +fun Preview_DevDrawerApp() { + DevDrawerApp() } diff --git a/app/src/main/java/de/psdev/devdrawer/ViewBindingBaseFragment.kt b/app/src/main/java/de/psdev/devdrawer/ViewBindingBaseFragment.kt new file mode 100644 index 00000000..d5d17130 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ViewBindingBaseFragment.kt @@ -0,0 +1,37 @@ +package de.psdev.devdrawer + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.viewbinding.ViewBinding + +abstract class ViewBindingBaseFragment : BaseFragment() { + + private var _binding: T? = null + + // This property is only valid between onCreateView and onDestroyView. + protected val binding get() = _binding!! + + final override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createViewBinding(inflater, container, savedInstanceState).also { viewBinding -> + _binding = viewBinding + }.root + + protected abstract fun createViewBinding( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): T + + @CallSuper + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + +} diff --git a/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt b/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt index fa33fa0b..19a64538 100644 --- a/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt +++ b/app/src/main/java/de/psdev/devdrawer/about/AboutFragment.kt @@ -13,13 +13,13 @@ 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.ViewBindingBaseFragment import de.psdev.devdrawer.databinding.FragmentAboutBinding import de.psdev.devdrawer.utils.consume @AndroidEntryPoint -class AboutFragment : BaseFragment() { +class AboutFragment : ViewBindingBaseFragment() { override fun createViewBinding( inflater: LayoutInflater, diff --git a/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt b/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt index cc685f15..6e4855a4 100644 --- a/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt +++ b/app/src/main/java/de/psdev/devdrawer/analytics/TrackingService.kt @@ -30,7 +30,7 @@ class TrackingService @Inject constructor( private val application: Application, private val remoteConfigService: RemoteConfigService, ) { - companion object : KLogging() { + companion object: KLogging() { const val PREF_KEY_OPTED_IN = "feature_analytics_opted_in" const val PREF_KEY_OPTED_IN_TIME = "feature_analytics_opted_in_time" const val CONFIG_KEY_ENABLED = "feature_analytics_enabled" diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt index 3aee1b41..d61ca917 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt @@ -5,6 +5,7 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable +import android.os.Build import androidx.recyclerview.widget.DiffUtil import mu.KotlinLogging import okio.HashingSink @@ -17,12 +18,12 @@ data class AppInfo( val name: String, val packageName: String, val appIcon: Drawable, - val firstInstalledTime: Long, + val firstInstallTime: Long, val lastUpdateTime: Long, - val signatureSha256: String + val signatureHashSha256: String ) { companion object { - val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + val DIFF_CALLBACK = object: DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: AppInfo, newItem: AppInfo): Boolean = oldItem.packageName == newItem.packageName @@ -47,7 +48,12 @@ val PackageInfo.signatureHashSha256: String get() { val hashingSink = HashingSink.sha256(blackholeSink()).use { it.buffer().use { bufferedSink -> - bufferedSink.write(signatures.first().toByteArray()) + val signatureBytes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + signingInfo.apkContentsSigners.first().toByteArray() + } else { + signatures.first().toByteArray() + } + bufferedSink.write(signatureBytes) } it } diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt index 5e5e9bf7..f1176c2e 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt @@ -6,20 +6,18 @@ import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.graphics.Color import android.net.Uri import android.widget.RemoteViews import dagger.hilt.android.AndroidEntryPoint import de.psdev.devdrawer.R import de.psdev.devdrawer.database.DevDrawerDatabase import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.receivers.UpdateReceiver -import de.psdev.devdrawer.utils.Constants import de.psdev.devdrawer.utils.textColorForBackground -import de.psdev.devdrawer.widgets.WidgetConfigActivity +import de.psdev.devdrawer.widgets.ui.WidgetConfigActivity +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import mu.KLogging import java.text.DateFormat @@ -30,51 +28,26 @@ import javax.inject.Inject * NOTE: Never rename this as it will break existing widgets. */ @AndroidEntryPoint -class DDWidgetProvider : AppWidgetProvider() { +class DDWidgetProvider: AppWidgetProvider() { @Inject lateinit var devDrawerDatabase: DevDrawerDatabase - companion object : KLogging() + companion object: KLogging() + + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) // ========================================================================================================================== // AppWidgetProvider // ========================================================================================================================== - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - when (intent.action) { - Constants.ACTION_WIDGET_PINNED -> GlobalScope.launch(Dispatchers.IO) { - val widgetDao = devDrawerDatabase.widgetDao() - val widgetProfileDao = devDrawerDatabase.widgetProfileDao() - val defaultWidgetProfile = widgetProfileDao.findAll().firstOrNull() - ?: WidgetProfile(name = "Default").also { - widgetProfileDao.insert(it) - } - val widgetId = intent.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID - ) - - // Create entries in database - val widget = Widget( - id = widgetId, - name = "Widget $widgetId", - color = Color.BLACK, - profileId = defaultWidgetProfile.id - ) - widgetDao.insert(widget) - UpdateReceiver.send(context) - } - } - } - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { super.onUpdate(context, appWidgetManager, appWidgetIds) - GlobalScope.launch(Dispatchers.IO) { + coroutineScope.launch { for (appWidgetId in appWidgetIds) { val widget = devDrawerDatabase.widgetDao().findById(appWidgetId) if (widget != null) { + logger.info { "Update Widget $appWidgetId" } updateWidget(context, widget, appWidgetManager) } else { logger.warn { "Widget $appWidgetId does not exist" } @@ -86,12 +59,13 @@ class DDWidgetProvider : AppWidgetProvider() { override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) logger.warn { "Deleted widgets ${appWidgetIds.joinToString()}" } - GlobalScope.launch(Dispatchers.IO) { + coroutineScope.launch { devDrawerDatabase.widgetDao().deleteByIds(appWidgetIds.toList()) } } private fun updateWidget(context: Context, widget: Widget, appWidgetManager: AppWidgetManager) { + logger.trace { "updateWidget(widget=$widget)" } try { val view = createRemoteViews(context, widget) appWidgetManager.updateAppWidget(widget.id, view) @@ -119,15 +93,19 @@ class DDWidgetProvider : AppWidgetProvider() { context, 0, Intent(context, UpdateReceiver::class.java), - PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) widgetView.setOnClickPendingIntent(R.id.btn_reload, reloadPendingIntent) val configActivityIntent = WidgetConfigActivity.createStartIntent(context, widget.id) configActivityIntent.putExtra("from_widget", true) configActivityIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK) - val configActivityPendingIntent = - PendingIntent.getActivity(context, 0, configActivityIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val configActivityPendingIntent = PendingIntent.getActivity( + context, + 0, + configActivityIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) widgetView.setOnClickPendingIntent(R.id.btn_settings, configActivityPendingIntent) // Apps list @@ -141,7 +119,12 @@ class DDWidgetProvider : AppWidgetProvider() { val clickIntent = Intent(context, ClickHandlingActivity::class.java).apply { addFlags(FLAG_ACTIVITY_NEW_TASK) } - val clickPI = PendingIntent.getActivity(context, 0, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val clickPI = PendingIntent.getActivity( + context, + 0, + clickIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) widgetView.setPendingIntentTemplate(R.id.listView, clickPI) return widgetView diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt index 7177a370..c51bb52c 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt @@ -1,5 +1,6 @@ package de.psdev.devdrawer.appwidget +import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo data class PackageHashInfo( @@ -9,4 +10,6 @@ data class PackageHashInfo( val signatureHashSha256: String ) -fun PackageInfo.toPackageHashInfo(): PackageHashInfo = PackageHashInfo(packageName, firstInstallTime, lastUpdateTime, signatureHashSha256) \ No newline at end of file +fun PackageInfo.toPackageHashInfo(): PackageHashInfo = PackageHashInfo(packageName, firstInstallTime, lastUpdateTime, signatureHashSha256) +val PackageInfo.isSystemApp: Boolean + get() = applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1 \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt index 42450ddb..92ae583b 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt @@ -146,7 +146,7 @@ class WidgetAppsListViewFactory( defaultSortOrder ) )) { - SortOrder.FIRST_INSTALLED -> compareByDescending { it.firstInstalledTime } + SortOrder.FIRST_INSTALLED -> compareByDescending { it.firstInstallTime } SortOrder.LAST_UPDATED -> compareByDescending { it.lastUpdateTime } SortOrder.NAME -> compareBy { it.name } SortOrder.PACKAGE_NAME -> compareBy { it.packageName } diff --git a/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt b/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt index 2d11c61d..029ccd39 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow abstract class PackageFilterDao : BaseDao() { @Query("SELECT * FROM filters WHERE id = :id") - abstract fun findById(id: String): PackageFilter? + abstract suspend fun findById(id: String): PackageFilter? @Query("SELECT * FROM filters WHERE profile_id = :profileId") abstract suspend fun findAllByProfile(profileId: String): List diff --git a/app/src/main/java/de/psdev/devdrawer/database/WidgetDao.kt b/app/src/main/java/de/psdev/devdrawer/database/WidgetDao.kt index 82cdd972..3e2c0fb4 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/WidgetDao.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/WidgetDao.kt @@ -2,10 +2,11 @@ package de.psdev.devdrawer.database import androidx.room.Dao import androidx.room.Query +import androidx.room.Transaction import kotlinx.coroutines.flow.Flow @Dao -abstract class WidgetDao : BaseDao() { +abstract class WidgetDao: BaseDao() { @Query("SELECT * FROM widgets") abstract suspend fun findAll(): List @@ -16,9 +17,16 @@ abstract class WidgetDao : BaseDao() { @Query("SELECT * FROM widgets") abstract fun findAllFlow(): Flow> + @Query("SELECT * FROM widgets WHERE profile_id = :profileId") + abstract suspend fun findAllByProfileId(profileId: String): List + @Query("SELECT * FROM widgets WHERE id = :id") abstract suspend fun findById(id: Int): Widget? + @Transaction + @Query("SELECT * FROM widgets WHERE id = :id") + abstract fun widgetWithIdObservable(id: Int): Flow + @Query("DELETE FROM widgets WHERE id IN (:ids)") abstract suspend fun deleteByIds(ids: List) diff --git a/app/src/main/java/de/psdev/devdrawer/database/WidgetProfileDao.kt b/app/src/main/java/de/psdev/devdrawer/database/WidgetProfileDao.kt index b190ac55..78e4c3f2 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,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 WidgetProfileDao: BaseDao() { +abstract class WidgetProfileDao : BaseDao() { @Query("SELECT * FROM widget_profiles") abstract suspend fun findAll(): List @@ -15,4 +16,8 @@ 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 + } diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt index 2969eefc..9da1a1dd 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/AppSignatureChooserBottomSheetDialogFragment.kt @@ -12,6 +12,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.firebase.ktx.Firebase import com.google.firebase.perf.ktx.performance import dagger.hilt.android.AndroidEntryPoint +import de.psdev.devdrawer.appwidget.isSystemApp import de.psdev.devdrawer.appwidget.toAppInfo import de.psdev.devdrawer.appwidget.toPackageHashInfo import de.psdev.devdrawer.database.DevDrawerDatabase @@ -42,7 +43,7 @@ class AppSignatureChooserBottomSheetDialogFragment : BottomSheetDialogFragment() private val onAppClickListener: AppInfoActionListener = { appInfo -> lifecycleScope.launch { val packageFilter = PackageFilter( - filter = appInfo.signatureSha256, + filter = appInfo.signatureHashSha256, type = FilterType.SIGNATURE, description = appInfo.name, profileId = navArgs.widgetProfileId @@ -76,6 +77,7 @@ class AppSignatureChooserBottomSheetDialogFragment : BottomSheetDialogFragment() val installedPackages = Firebase.performance.trace("widget_profile_packages") { packageManager.getInstalledPackages(PackageManager.GET_SIGNATURES) .asSequence() + .filterNot { it.isSystemApp } // TODO Option to allow system apps? .map { it.toPackageHashInfo() } .distinctBy { it.signatureHashSha256 } .filter { hashInfo -> filters.none { it.type == FilterType.SIGNATURE && it.filter == hashInfo.signatureHashSha256 } } diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/AppsService.kt b/app/src/main/java/de/psdev/devdrawer/profiles/AppsService.kt new file mode 100644 index 00000000..72c02c45 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/AppsService.kt @@ -0,0 +1,55 @@ +package de.psdev.devdrawer.profiles + +import android.app.Application +import android.content.pm.PackageManager.GET_SIGNATURES +import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES +import android.os.Build +import com.google.firebase.ktx.Firebase +import com.google.firebase.perf.ktx.performance +import de.psdev.devdrawer.appwidget.* +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.utils.trace +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppsService @Inject constructor( + private val application: Application +) { + + private val packageManager by lazy { application.packageManager } + + suspend fun getAppsForPackageFilter( + packageFilter: PackageFilter + ): List = Firebase.performance.trace("getAppsForPackageFilter") { + withContext(Dispatchers.IO) { + packageManager.getInstalledPackages(getFlags()) + .asSequence() + .filterNot { it.isSystemApp } // TODO Option to allow system apps? + .map { it.toPackageHashInfo() } + .filter { packageFilter.matches(it) } + .mapNotNull { it.toAppInfo(application) } + .sortedBy { it.name } + .toList() + } + } + + suspend fun getInstalledPackages(): List = Firebase.performance.trace("getInstalledPackages") { + withContext(Dispatchers.IO) { + packageManager.getInstalledPackages(getFlags()) + .asSequence() + .filterNot { it.isSystemApp } // TODO Option to allow system apps? + .map { it.toPackageHashInfo() } + .toList() + } + } + + private fun getFlags() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + GET_SIGNING_CERTIFICATES + } else { + GET_SIGNATURES + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterListAdapter.kt deleted file mode 100644 index bbd49014..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterListAdapter.kt +++ /dev/null @@ -1,101 +0,0 @@ -package de.psdev.devdrawer.profiles - -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import de.psdev.devdrawer.R -import de.psdev.devdrawer.database.FilterType -import de.psdev.devdrawer.database.PackageFilter -import de.psdev.devdrawer.databinding.ListItemPackageFilterBinding -import de.psdev.devdrawer.utils.layoutInflater - -class PackageFilterListAdapter( - private val onDeleteClickListener: PackageFilterActionListener, - private val onPreviewFilterClickListener: PackageFilterActionListener -) : ListAdapter(PackageFilter.DIFF_CALLBACK) { - - var selectionTracker: SelectionTracker? = null - - // ========================================================================================================================== - // ListAdapter - // ========================================================================================================================== - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PackageFilterViewHolder { - val onClickListener: PackageFilterActionListener = { packageFilter -> - selectionTracker?.select(packageFilter.id) - } - return PackageFilterViewHolder( - binding = ListItemPackageFilterBinding.inflate(parent.layoutInflater, parent, false), - onClickListener = onClickListener, - onDeleteClickListener = onDeleteClickListener, - onPreviewFilterClickListener = onPreviewFilterClickListener - ) - } - - override fun onBindViewHolder(holder: PackageFilterViewHolder, position: Int) { - val packageFilter = getItem(position) - val isSelected = selectionTracker?.isSelected(packageFilter.id) ?: false - holder.bindTo(packageFilter, isSelected) - } - - public override fun getItem(position: Int): PackageFilter = super.getItem(position) - - class PackageFilterViewHolder( - private val binding: ListItemPackageFilterBinding, - private val onClickListener: PackageFilterActionListener, - private val onDeleteClickListener: PackageFilterActionListener, - private val onPreviewFilterClickListener: PackageFilterActionListener - ) : RecyclerView.ViewHolder(binding.root) { - var currentItem: PackageFilter? = null - private set - - fun bindTo(packageFilter: PackageFilter, isActivated: Boolean = false) { - currentItem = packageFilter - with(binding) { - root.isActivated = isActivated - root.setOnClickListener { - onClickListener(packageFilter) - } - val iconRes = when (packageFilter.type) { - FilterType.PACKAGE_NAME -> R.drawable.ic_regex - FilterType.SIGNATURE -> R.drawable.ic_certificate - } - imgIcon.setImageResource(iconRes) - txtName.text = when (packageFilter.type) { - FilterType.PACKAGE_NAME -> packageFilter.filter - FilterType.SIGNATURE -> packageFilter.description - } - - with(btnPreview) { - setOnClickListener { - onPreviewFilterClickListener(packageFilter) - } - } - with(btnInfo) { - isVisible = packageFilter.type == FilterType.SIGNATURE - setOnClickListener { - val text = when (packageFilter.type) { - FilterType.PACKAGE_NAME -> packageFilter.description - FilterType.SIGNATURE -> "SHA256: ${ - packageFilter.filter.uppercase().chunkedSequence(2) - .joinToString(separator = ":") - }" - } - MaterialAlertDialogBuilder(itemView.context) - .setTitle(R.string.info) - .setMessage(text) - .setPositiveButton(R.string.close, null) - .show() - } - } - btnDelete.setOnClickListener { - onDeleteClickListener(packageFilter) - } - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt b/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt new file mode 100644 index 00000000..230fc569 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt @@ -0,0 +1,28 @@ +package de.psdev.devdrawer.profiles + +import android.app.Application +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.receivers.UpdateReceiver +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PackageFilterRepository @Inject constructor( + private val application: Application, + private val devDrawerDatabase: DevDrawerDatabase +) { + + suspend fun getById(packageFilterId: String) = devDrawerDatabase.packageFilterDao().findById(packageFilterId) + + suspend fun delete(packageFilter: PackageFilter) { + devDrawerDatabase.packageFilterDao().delete(packageFilter) + UpdateReceiver.send(application) + } + + suspend fun save(packageFilter: PackageFilter) { + devDrawerDatabase.packageFilterDao().insert(packageFilter) + UpdateReceiver.send(application) + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt new file mode 100644 index 00000000..eaee2e96 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt @@ -0,0 +1,65 @@ +package de.psdev.devdrawer.profiles + +import android.content.res.Configuration +import androidx.compose.foundation.combinedClickable +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.material.* +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.WidgetProfile +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@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.body1, + text = widgetProfile.name + ) + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + Text( + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.body2, + text = stringResource(id = R.string.widget_profile_id_template, widgetProfile.id) + ) + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@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..66f8c1f6 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileList.kt @@ -0,0 +1,48 @@ +package de.psdev.devdrawer.profiles + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun WidgetProfileList( + widgetProfiles: List, + onWidgetProfileClick: (WidgetProfile) -> Unit = {}, + onWidgetProfileLongClick: (WidgetProfile) -> Unit = {}, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + items(widgetProfiles, key = { it.id }) { widgetProfile -> + WidgetProfileCard( + widgetProfile = widgetProfile, + onWidgetProfileClick = onWidgetProfileClick, + onWidgetProfileLongClick = onWidgetProfileLongClick + ) + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetProfileList() { + DevDrawerTheme { + WidgetProfileList( + listOf( + WidgetProfile(name = "Profile 1"), + WidgetProfile(name = "Profile 2") + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt index 769ee55f..0cb63127 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileListFragment.kt @@ -1,89 +1,43 @@ package de.psdev.devdrawer.profiles -import android.database.sqlite.SQLiteConstraintException import android.os.Bundle -import android.view.* -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import com.google.android.material.snackbar.Snackbar +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.material.AlertDialog +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource import dagger.hilt.android.AndroidEntryPoint import de.psdev.devdrawer.BaseFragment import de.psdev.devdrawer.R import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.Widget import de.psdev.devdrawer.database.WidgetProfile -import de.psdev.devdrawer.databinding.FragmentWidgetProfileListBinding -import de.psdev.devdrawer.utils.awaitSubmit -import de.psdev.devdrawer.utils.consume -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch +import de.psdev.devdrawer.ui.theme.DevDrawerTheme import mu.KLogging import javax.inject.Inject @AndroidEntryPoint -class WidgetProfileListFragment: BaseFragment() { +class WidgetProfileListFragment: BaseFragment() { companion object: KLogging() - // Dependencies @Inject lateinit var devDrawerDatabase: DevDrawerDatabase - val listAdapter: WidgetProfilesListAdapter = WidgetProfilesListAdapter() - var _selectionTracker: SelectionTracker? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - } - - override fun createViewBinding(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): FragmentWidgetProfileListBinding = - FragmentWidgetProfileListBinding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerProfiles.adapter = listAdapter - val selectionTracker = SelectionTracker.Builder( - "widgetProfile", - binding.recyclerProfiles, - WidgetProfilesItemKeyProvider(listAdapter), - WidgetProfilesDetailsLookup(binding.recyclerProfiles), - StorageStrategy.createStringStorage() - ).withSelectionPredicate(SelectionPredicates.createSelectSingleAnything()).build().also { tracker -> - tracker.onRestoreInstanceState(savedInstanceState) - tracker.addObserver(object: SelectionTracker.SelectionObserver() { - override fun onSelectionChanged() { - super.onSelectionChanged() - activity?.invalidateOptionsMenu() - } - }) - _selectionTracker = tracker - } - listAdapter.selectionTracker = selectionTracker - viewLifecycleOwner.lifecycleScope.launch { - val widgetProfileDao = devDrawerDatabase.widgetProfileDao() - widgetProfileDao.findAllFlow().collect { - logger.warn { "$it" } - listAdapter.awaitSubmit(it) - binding.recyclerProfiles.scrollToPosition(it.indexOfFirst { selectionTracker.isSelected(it.id) }) } } - childFragmentManager.setFragmentResultListener("createProfile", viewLifecycleOwner) { _, bundle -> - // We use a String here, but any type that can be put in a Bundle is supported - val result = bundle.getString("profileId") ?: selectionTracker.selection.firstOrNull() ?: "" - selectionTracker.select(result) - } - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - inflater.inflate(R.menu.menu_profiles_list, menu) - val hasSelection = _selectionTracker?.hasSelection() ?: false - menu.findItem(R.id.action_create).isVisible = !hasSelection - menu.findItem(R.id.action_edit).isVisible = hasSelection - menu.findItem(R.id.action_delete).isVisible = hasSelection } override fun onResume() { @@ -91,52 +45,39 @@ class WidgetProfileListFragment: BaseFragment( updateToolbarTitle(R.string.profiles) } - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - R.id.action_create -> consume { - lifecycleScope.launchWhenResumed { - val widgetProfileDao = devDrawerDatabase.widgetProfileDao() - val size = widgetProfileDao.findAll().size - val widgetProfile = WidgetProfile(name = "Profile ${size + 1}") - widgetProfileDao.insert(widgetProfile) - findNavController().navigate(WidgetProfileListFragmentDirections.editWidgetProfile(widgetProfile.id)) - } - } - R.id.action_edit -> consume { - val selectedId = _selectionTracker?.selection?.firstOrNull() - if (selectedId != null) { - findNavController().navigate(WidgetProfileListFragmentDirections.editWidgetProfile(selectedId)) - } - } - R.id.action_delete -> consume { - lifecycleScope.launchWhenStarted { - _selectionTracker?.let { tracker -> - val selectedProfile = tracker.selection.firstOrNull() - if (selectedProfile != null) { - val widgetProfile = devDrawerDatabase.widgetProfileDao().findById(selectedProfile) - if (widgetProfile != null) { - try { - devDrawerDatabase.widgetProfileDao().delete(widgetProfile) - } catch (e: SQLiteConstraintException) { - Snackbar.make(binding.root, R.string.error_profile_in_use, Snackbar.LENGTH_LONG).show() - } - } - tracker.deselect(selectedProfile) - } - } +} + +@Composable +fun WidgetInUseErrorAlertDialog( + state: DeleteDialogState.InUseError, + onDismiss: () -> Unit = {} +) { + AlertDialog( + onDismissRequest = { }, + title = { + Text(text = "Error") + }, + text = { + Text(text = "The profile ${state.widgetProfile.name} is used by: \n" + state.widgets.joinToString("\n") { it.name }) + }, + confirmButton = { + TextButton(onClick = { + onDismiss() + }) { + Text(stringResource(id = R.string.close)) } } - else -> super.onOptionsItemSelected(item) - } + ) +} - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - _selectionTracker?.onSaveInstanceState(outState) - } +sealed class DeleteDialogState { + object Hidden: DeleteDialogState() + data class Showing( + val widgetProfile: WidgetProfile + ): DeleteDialogState() - override fun onDestroyView() { - _selectionTracker = null - listAdapter.selectionTracker = null - binding.recyclerProfiles.adapter = null - super.onDestroyView() - } + data class InUseError( + val widgetProfile: WidgetProfile, + val widgets: List + ): DeleteDialogState() } diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileRepository.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileRepository.kt new file mode 100644 index 00000000..b07bc633 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileRepository.kt @@ -0,0 +1,24 @@ +package de.psdev.devdrawer.profiles + +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.WidgetProfile +import kotlinx.coroutines.flow.distinctUntilChanged +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WidgetProfileRepository @Inject constructor( + private val devDrawerDatabase: DevDrawerDatabase +) { + + fun widgetProfilesFlow() = devDrawerDatabase.widgetProfileDao().findAllFlow().distinctUntilChanged() + suspend fun delete(widgetProfile: WidgetProfile) { + devDrawerDatabase.widgetProfileDao().delete(widgetProfile) + } + + suspend fun findAll(): List = devDrawerDatabase.widgetProfileDao().findAll() + suspend fun create(widgetProfile: WidgetProfile) { + devDrawerDatabase.widgetProfileDao().insert(widgetProfile) + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesDetailsLookup.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesDetailsLookup.kt index a321bae2..fa19b009 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesDetailsLookup.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesDetailsLookup.kt @@ -3,6 +3,7 @@ package de.psdev.devdrawer.profiles import android.view.MotionEvent import androidx.recyclerview.selection.ItemDetailsLookup import androidx.recyclerview.widget.RecyclerView +import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesListAdapter class WidgetProfilesDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup() { override fun getItemDetails(event: MotionEvent): ItemDetails? { diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt index 392d9881..6b7b21fa 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesItemKeyProvider.kt @@ -1,6 +1,7 @@ package de.psdev.devdrawer.profiles import androidx.recyclerview.selection.ItemKeyProvider +import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesListAdapter class WidgetProfilesItemKeyProvider(private val adapter: WidgetProfilesListAdapter): ItemKeyProvider( SCOPE_MAPPED diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt new file mode 100644 index 00000000..66126e66 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt @@ -0,0 +1,49 @@ +package de.psdev.devdrawer.profiles + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.WidgetProfile +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WidgetProfilesViewModel @Inject constructor( + private val widgetProfileRepository: WidgetProfileRepository, + val devDrawerDatabase: DevDrawerDatabase +): ViewModel() { + + val viewState = MutableStateFlow(ViewState.Loading) + + init { + viewModelScope.launch { + widgetProfileRepository.widgetProfilesFlow().collect { + delay(100) + viewState.value = ViewState.Loaded(it) + } + } + } + + suspend fun deleteProfile(widgetProfile: WidgetProfile) { + widgetProfileRepository.delete(widgetProfile) + } + + suspend fun createNewProfile(): WidgetProfile { + val size = widgetProfileRepository.findAll().size + val widgetProfile = WidgetProfile(name = "Profile ${size + 1}") + widgetProfileRepository.create(widgetProfile) + return widgetProfile + } + + sealed class ViewState { + object Loading: ViewState() + data class Loaded( + val data: List + ): ViewState() + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt new file mode 100644 index 00000000..fb7ccf38 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt @@ -0,0 +1,118 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +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.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.navigation.compose.hiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.ui.editor.AddAppSignaturePackageFilterDialogViewModel.ViewState +import de.psdev.devdrawer.ui.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 + ) +} + +@Composable +private fun AddAppSignaturePackageFilterDialog( + viewState: ViewState, + closeDialog: () -> Unit = {}, + appSelected: (AppInfo) -> Unit = {} +) { + Dialog( + onDismissRequest = closeDialog, + properties = DialogProperties(dismissOnClickOutside = false) + ) { + Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colors.surface, shape = MaterialTheme.shapes.medium) { + Column(modifier = Modifier.padding(8.dp)) { + Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painter = painterResource(id = R.drawable.ic_certificate), contentDescription = stringResource(id = R.string.app_signature)) + Spacer(modifier = Modifier.size(4.dp)) + Text(modifier = Modifier.weight(1f), text = stringResource(id = R.string.enter_package_name_filter)) + } + Spacer(modifier = Modifier.size(4.dp)) + Divider() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + when (viewState) { + ViewState.Loading -> LoadingView( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), showText = false + ) + is ViewState.Loaded -> { + Column { + if (viewState.data.isEmpty()) { + Text(text = stringResource(id = R.string.no_apps_available)) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + ) { + items(viewState.data) { + AppInfoItem(appInfo = it, onAppClicked = appSelected) + } + } + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton(modifier = Modifier.align(Alignment.End), onClick = closeDialog) { + Text(text = stringResource(id = R.string.cancel).toUpperCase(Locale.current)) + } + } + } + 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() + ) + ) + } + } +} \ 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..7c680a04 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialogViewModel.kt @@ -0,0 +1,38 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import android.app.Application +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.toAppInfo +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.AppsService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +@HiltViewModel +class AddAppSignaturePackageFilterDialogViewModel @Inject constructor( + private val application: Application, + private val appsService: AppsService +): ViewModel() { + + fun availableApps(currentFilters: List) = flow { + val availableApps = appsService.getInstalledPackages() + .filter { currentFilters.none { packageFilter -> packageFilter.matches(it) } } + .mapNotNull { it.toAppInfo(application) } + .sortedBy { it.name } + emit(ViewState.Loaded(availableApps)) + }.flowOn(Dispatchers.IO) + + sealed class ViewState { + object Loading: ViewState() { + override fun toString(): String = javaClass.simpleName + } + + 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/AddPackageNamePackageFilterDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialog.kt new file mode 100644 index 00000000..195a31b6 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialog.kt @@ -0,0 +1,117 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel.ViewState.* +import de.psdev.devdrawer.ui.autocomplete.AutoCompleteTextView +import de.psdev.devdrawer.ui.loading.LoadingView +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import java.util.* + +@Composable +fun AddPackageNamePackageFilterDialog( + currentFilters: List, + viewModel: AddPackageNamePackageFilterDialogViewModel = hiltViewModel(), + closeDialog: () -> Unit = {}, + addFilter: (String) -> Unit = {} +) { + val viewState by remember(viewModel) { viewModel.availablePackageFilters(currentFilters) } + .collectAsState(initial = Loading) + AddPackageNamePackageFilterDialog( + viewState = viewState, + closeDialog = closeDialog, + addFilter = addFilter + ) +} + +@Composable +private fun AddPackageNamePackageFilterDialog( + viewState: AddPackageNamePackageFilterDialogViewModel.ViewState, + closeDialog: () -> Unit = {}, + addFilter: (String) -> Unit = {} +) { + Dialog( + onDismissRequest = closeDialog, + properties = DialogProperties(dismissOnClickOutside = false) + ) { + Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colors.surface, shape = MaterialTheme.shapes.medium) { + Column(modifier = Modifier.padding(8.dp)) { + Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painter = painterResource(id = R.drawable.ic_certificate), contentDescription = stringResource(id = R.string.app_signature)) + Spacer(modifier = Modifier.size(4.dp)) + Text(modifier = Modifier.weight(1f), text = stringResource(id = R.string.enter_package_name_filter)) + } + Spacer(modifier = Modifier.size(4.dp)) + Divider() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + when (viewState) { + Loading -> LoadingView( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), showText = false + ) + is Loaded -> { + Column { + var text by remember { mutableStateOf("") } + AutoCompleteTextView( + options = viewState.data, + label = { Text(text = stringResource(id = R.string.packagefilter)) }, + onTextChanged = { text = it } + ) + Spacer(modifier = Modifier.size(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = closeDialog) { + Text(text = stringResource(id = R.string.cancel).uppercase(Locale.getDefault())) + } + TextButton(onClick = { addFilter(text) }, enabled = text.isNotBlank()) { + Text(text = stringResource(id = R.string.add).uppercase(Locale.getDefault())) + } + } + } + } + is Error -> Text(text = "Error: ${viewState.message}") + } + } + } + } + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_AddPackageNamePackageFilterDialog() { + DevDrawerTheme { + Surface { + AddPackageNamePackageFilterDialog( + viewState = Loaded( + listOf( + "com.example.1", + "com.example.2", + "com.example.3", + "com.example.4", + "com.example.5", + "com.example.6", + ) + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialogViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialogViewModel.kt new file mode 100644 index 00000000..8729bbfc --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialogViewModel.kt @@ -0,0 +1,51 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.AppsService +import kotlinx.coroutines.flow.flow +import java.text.Collator +import javax.inject.Inject + +@HiltViewModel +class AddPackageNamePackageFilterDialogViewModel @Inject constructor( + private val appsService: AppsService +): ViewModel() { + + fun availablePackageFilters(currentFilters: List) = flow { + val packageNameFilters = appsService.getInstalledPackages() + .filter { currentFilters.none { packageFilter -> packageFilter.matches(it) } } + .map { it.packageName } + .splitIntoFilters() + emit(ViewState.Loaded(packageNameFilters)) + } + + private fun List.splitIntoFilters(): List { + val appSet = mutableSetOf() + forEach { packageName -> + var tempPackageName = packageName + appSet.add(tempPackageName) + while (tempPackageName.isNotEmpty()) { + val lastIndex = tempPackageName.lastIndexOf(".") + if (lastIndex > 0) { + tempPackageName = tempPackageName.substring(0, lastIndex) + appSet.add("$tempPackageName.*") + } else { + tempPackageName = "" + } + } + } + return appSet.toList().sortedWith(Collator.getInstance()) + } + + sealed class ViewState { + object Loading: ViewState() { + override fun toString(): String = javaClass.simpleName + } + + 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..c37a3523 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt @@ -0,0 +1,69 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun AppInfoItem( + appInfo: AppInfo, + onAppClicked: (AppInfo) -> Unit = {} +) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .clickable { + onAppClicked(appInfo) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier + .size(64.dp) + .padding(8.dp), + bitmap = appInfo.appIcon.toBitmap().asImageBitmap(), + contentDescription = "App icon" + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(modifier = Modifier.weight(1f), text = appInfo.name, style = MaterialTheme.typography.body1) + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_AppInfoItem() { + val context = LocalContext.current + val resources = context.resources + DevDrawerTheme { + Surface { + AppInfoItem( + appInfo = AppInfo( + name = "Test app", + packageName = "Test package", + appIcon = ResourcesCompat.getDrawable(resources, R.drawable.ic_launcher_foreground, context.theme)!!, + firstInstallTime = System.currentTimeMillis(), + lastUpdateTime = System.currentTimeMillis(), + signatureHashSha256 = "1234" + ) + ) + } + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt new file mode 100644 index 00000000..fcf8ad60 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt @@ -0,0 +1,33 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import androidx.compose.material.AlertDialog +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.psdev.devdrawer.R +import java.util.* + +@Composable +fun DeletePackageFilterDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(id = R.string.delete_profile)) + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = R.string.cancel).uppercase(Locale.getDefault())) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(id = R.string.yes).uppercase(Locale.getDefault())) + } + } + ) + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt new file mode 100644 index 00000000..d71a95cc --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt @@ -0,0 +1,114 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.content.res.ResourcesCompat +import androidx.hilt.navigation.compose.hiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel.ViewState.* +import de.psdev.devdrawer.ui.loading.LoadingView +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import java.util.* + +@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 = {} +) { + Dialog( + onDismissRequest = { closeDialog() } + ) { + Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colors.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.apps_matching_filter)) + } + Spacer(modifier = Modifier.size(4.dp)) + Divider() + Box(modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp)) { + when (viewState) { + Loading -> LoadingView(modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), showText = false) + is Loaded -> LazyColumn { + items(viewState.data) { appInfo -> + AppInfoItem(appInfo = appInfo) + } + } + is Error -> Text(text = "Error: ${viewState.message}") + } + } + TextButton(modifier = Modifier.align(Alignment.End), onClick = closeDialog) { + Text(text = stringResource(id = R.string.close).uppercase(Locale.getDefault())) + } + } + } + } +} + +@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"), + baseAppInfo.copy(name = "App 3"), + baseAppInfo.copy(name = "App 4"), + baseAppInfo.copy(name = "App 5"), + baseAppInfo.copy(name = "App 6"), + baseAppInfo.copy(name = "App 7"), + ) + ) + ) + } + } +} \ 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..6fd2d8a1 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialogViewModel.kt @@ -0,0 +1,34 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.AppsService +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +@HiltViewModel +class PackageFilterPreviewDialogViewModel @Inject constructor( + private val appsService: AppsService +): ViewModel() { + + fun load(packageFilter: PackageFilter) = flow { + try { + val appsForPackageFilter = appsService.getAppsForPackageFilter(packageFilter) + emit(ViewState.Loaded(appsForPackageFilter)) + } catch (e: Exception) { + emit(ViewState.Error(e.message.orEmpty())) + } + } + + sealed class ViewState { + object Loading: ViewState() { + override fun toString(): String = javaClass.simpleName + } + + 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..c21fcb3e --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt @@ -0,0 +1,351 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +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.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import kotlinx.coroutines.launch +import java.util.* + +@Composable +fun WidgetProfileEditor( + viewModel: WidgetProfileEditorViewModel = hiltViewModel() +) { + val viewState by viewModel.state.collectAsState(initial = WidgetProfileEditorViewState.Empty) + var currentDialog by remember { mutableStateOf(WidgetProfileEditorDialogs.None) } + val coroutineScope = rememberCoroutineScope() + + + Box(modifier = Modifier.fillMaxSize()) { + WidgetProfileEditor( + viewState = viewState, + onNameChange = { + viewModel.onNameChanged(it) + }, + onSaveNameClick = { + viewModel.saveChanges(viewState) + }, + onAddPackageFilterClick = { + currentDialog = WidgetProfileEditorDialogs.AddPackageNamePackageFilter(viewState.packageFilters) + }, + onAddAppSignatureClick = { + currentDialog = WidgetProfileEditorDialogs.AddAppSignaturePackageFilter(viewState.packageFilters) + }, + onPackageFilterPreviewClick = { + currentDialog = WidgetProfileEditorDialogs.PackageFilterPreview(it) + }, + onPackageFilterInfoClick = { }, + onDeletePackageFilterClick = { + currentDialog = WidgetProfileEditorDialogs.DeletePackageFilter(it) + } + ) + } + when (val dialog = currentDialog) { + WidgetProfileEditorDialogs.None -> Unit + is WidgetProfileEditorDialogs.AddAppSignaturePackageFilter -> AddAppSignaturePackageFilterDialog( + currentFilters = dialog.currentPackageFilters, + closeDialog = { + currentDialog = WidgetProfileEditorDialogs.None + }, + appSelected = { appInfo -> + coroutineScope.launch { + viewModel.addPackageFilter( + PackageFilter( + filter = appInfo.signatureHashSha256, + type = FilterType.SIGNATURE, + description = appInfo.name, + profileId = viewState.widgetProfile?.id.orEmpty() + ) + ) + currentDialog = WidgetProfileEditorDialogs.None + } + } + ) + is WidgetProfileEditorDialogs.AddPackageNamePackageFilter -> AddPackageNamePackageFilterDialog( + currentFilters = dialog.currentPackageFilters, + closeDialog = { + currentDialog = WidgetProfileEditorDialogs.None + }, + addFilter = { packageNameFilter -> + coroutineScope.launch { + viewModel.addPackageFilter( + PackageFilter( + type = FilterType.PACKAGE_NAME, + filter = packageNameFilter, + profileId = viewState.widgetProfile?.id.orEmpty() + ) + ) + currentDialog = WidgetProfileEditorDialogs.None + } + } + ) + is WidgetProfileEditorDialogs.PackageFilterPreview -> PackageFilterPreviewDialog(packageFilter = dialog.packageFilter) { + currentDialog = WidgetProfileEditorDialogs.None + } + is WidgetProfileEditorDialogs.PackageFilterInfo -> Unit + is WidgetProfileEditorDialogs.DeletePackageFilter -> { + DeletePackageFilterDialog( + onDismiss = { + currentDialog = WidgetProfileEditorDialogs.None + }, + onConfirm = { + coroutineScope.launch { + viewModel.deleteFilter(dialog.packageFilter) + currentDialog = WidgetProfileEditorDialogs.None + } + } + ) + } + } +} + +private sealed class WidgetProfileEditorDialogs { + object None: WidgetProfileEditorDialogs() + + data class PackageFilterPreview( + val packageFilter: PackageFilter + ): WidgetProfileEditorDialogs() + + data class AddPackageNamePackageFilter( + val currentPackageFilters: List + ): WidgetProfileEditorDialogs() + + data class AddAppSignaturePackageFilter( + val currentPackageFilters: List + ): WidgetProfileEditorDialogs() + + data class PackageFilterInfo( + val packageFilter: PackageFilter + ): WidgetProfileEditorDialogs() + + data class DeletePackageFilter( + val packageFilter: PackageFilter + ): WidgetProfileEditorDialogs() +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun WidgetProfileEditor( + viewState: WidgetProfileEditorViewState, + onNameChange: (String) -> Unit = {}, + onSaveNameClick: () -> Unit = {}, + onAddPackageFilterClick: (WidgetProfile) -> Unit = {}, + onAddAppSignatureClick: (WidgetProfile) -> Unit = {}, + onPackageFilterPreviewClick: (PackageFilter) -> Unit = {}, + onPackageFilterInfoClick: (PackageFilter) -> Unit = {}, + onDeletePackageFilterClick: (PackageFilter) -> Unit = {} +) { + val widgetProfile = viewState.widgetProfile + if (widgetProfile == null) { + // Loading + Box(modifier = Modifier.defaultMinSize(minHeight = 256.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + } + } else { + Column(modifier = Modifier.defaultMinSize(minHeight = 256.dp)) { + Surface(modifier = Modifier.wrapContentHeight(), elevation = 2.dp) { + Column( + modifier = Modifier + .wrapContentHeight() + .padding(8.dp) + ) { + WidgetProfileName( + widgetName = viewState.widgetName ?: widgetProfile.name, + currentName = widgetProfile.name, + onNameChange = onNameChange, + onSaveNameClick = onSaveNameClick + ) + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + modifier = Modifier.weight(1f), + onClick = { onAddPackageFilterClick(widgetProfile) } + ) { + Icon( + modifier = Modifier.size(ButtonDefaults.IconSize), + painter = painterResource(id = R.drawable.ic_regex), + contentDescription = stringResource(id = R.string.add_package_name) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(id = R.string.add_package_name).uppercase(Locale.getDefault())) + } + Button( + modifier = Modifier.weight(1f), + onClick = { onAddAppSignatureClick(widgetProfile) } + ) { + Icon( + modifier = Modifier.size(ButtonDefaults.IconSize), + painter = painterResource(id = R.drawable.ic_certificate), + contentDescription = stringResource(id = R.string.add_app_signature) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(id = R.string.add_app_signature).uppercase(Locale.getDefault())) + } + } + } + } + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(viewState.packageFilters) { packageFilter -> + Card { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val iconRes = when (packageFilter.type) { + FilterType.PACKAGE_NAME -> R.drawable.ic_regex + FilterType.SIGNATURE -> R.drawable.ic_certificate + } + Icon( + modifier = Modifier.padding(8.dp), + painter = painterResource(id = iconRes), + contentDescription = null + ) + val text = when (packageFilter.type) { + FilterType.PACKAGE_NAME -> packageFilter.filter + FilterType.SIGNATURE -> packageFilter.description + } + Text(modifier = Modifier.weight(1f), text = text) + AnimatedVisibility(visible = packageFilter.type == FilterType.SIGNATURE) { + Icon( + modifier = Modifier + .clickable { onPackageFilterInfoClick(packageFilter) } + .padding(8.dp), + imageVector = Icons.Filled.Info, + contentDescription = null + ) + } + Icon( + modifier = Modifier + .clickable { onPackageFilterPreviewClick(packageFilter) } + .padding(8.dp), + imageVector = Icons.Filled.Preview, + contentDescription = null + ) + Icon( + modifier = Modifier + .clickable { onDeletePackageFilterClick(packageFilter) } + .padding(8.dp), + imageVector = Icons.Filled.Delete, + contentDescription = null + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun WidgetProfileName( + widgetName: String, + currentName: String, + onNameChange: (String) -> Unit = {}, + onSaveNameClick: () -> Unit = {} +) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.weight(1f), + singleLine = true, + value = widgetName, + onValueChange = onNameChange, + label = { Text(text = stringResource(id = R.string.name)) } + ) + AnimatedVisibility(visible = widgetName != currentName) { + Button( + modifier = Modifier.weight(1f), + onClick = onSaveNameClick + ) { + Icon( + modifier = Modifier.size(ButtonDefaults.IconSize), + imageVector = Icons.Outlined.Save, + contentDescription = stringResource(id = R.string.save) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(id = R.string.save)) + } + } + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetProfileEditor_Loading() { + DevDrawerTheme { + WidgetProfileEditor( + viewState = WidgetProfileEditorViewState.Empty + ) + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetProfileEditor_Loaded() { + val widgetProfile = WidgetProfile( + id = UUID.randomUUID().toString(), + name = "Test widget profile" + ) + DevDrawerTheme { + WidgetProfileEditor( + viewState = WidgetProfileEditorViewState( + widgetProfile = widgetProfile, + widgetName = widgetProfile.name, + packageFilters = listOf( + PackageFilter(profileId = widgetProfile.id, filter = "01022402020", type = FilterType.SIGNATURE), + PackageFilter(profileId = widgetProfile.id, filter = "com.example2.*") + ) + ) + ) + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetProfileEditor_NameChanged() { + DevDrawerTheme { + WidgetProfileEditor( + viewState = WidgetProfileEditorViewState( + widgetProfile = WidgetProfile( + id = UUID.randomUUID().toString(), + name = "Test widget profile" + ), + widgetName = "Test widget profile 2" + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt new file mode 100644 index 00000000..74f7ec3a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt @@ -0,0 +1,61 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.PackageFilterRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WidgetProfileEditorViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val database: DevDrawerDatabase, + private val packageFilterRepository: PackageFilterRepository +): ViewModel() { + + private val widgetProfileId: String = savedStateHandle.get("profileId")!! + private val widgetNameState: MutableStateFlow = MutableStateFlow(null) + + val state = combine( + database.widgetProfileDao().widgetProfileWithIdObservable(widgetProfileId), + database.packageFilterDao().findAllByProfileFlow(widgetProfileId).distinctUntilChanged(), + widgetNameState + ) { widgetProfile, packageFilters, name -> + WidgetProfileEditorViewState( + widgetProfile = widgetProfile, + widgetName = name ?: widgetProfile.name, + packageFilters = packageFilters + ) + } + + fun onNameChanged(newName: String) { + widgetNameState.value = newName + } + + fun saveChanges(viewState: WidgetProfileEditorViewState) { + val widgetProfile = viewState.widgetProfile ?: return + viewModelScope.launch { + database.widgetProfileDao().update( + widgetProfile.copy( + name = viewState.widgetName ?: widgetProfile.name + ) + ) + } + } + + suspend fun addPackageFilter(packageFilter: PackageFilter) { + packageFilterRepository.save(packageFilter) + } + + suspend fun deleteFilter(packageFilter: PackageFilter) { + packageFilterRepository.delete(packageFilter) + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt new file mode 100644 index 00000000..09593068 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt @@ -0,0 +1,16 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import androidx.compose.runtime.Immutable +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.WidgetProfile + +@Immutable +data class WidgetProfileEditorViewState( + val widgetProfile: WidgetProfile? = null, + val widgetName: String? = null, + val packageFilters: List = emptyList() +) { + companion object { + val Empty = WidgetProfileEditorViewState() + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListAdapter.kt similarity index 93% rename from app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListAdapter.kt rename to app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListAdapter.kt index 8a3d8065..072b174d 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListAdapter.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListAdapter.kt @@ -1,4 +1,4 @@ -package de.psdev.devdrawer.profiles +package de.psdev.devdrawer.profiles.ui.list import android.view.ViewGroup import androidx.recyclerview.selection.SelectionTracker @@ -6,7 +6,8 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.databinding.ListItemWidgetProfileBinding -import de.psdev.devdrawer.profiles.WidgetProfilesListAdapter.WidgetProfileViewHolder +import de.psdev.devdrawer.profiles.WidgetActionListener +import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesListAdapter.WidgetProfileViewHolder import de.psdev.devdrawer.utils.consume import de.psdev.devdrawer.utils.layoutInflater diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt new file mode 100644 index 00000000..c9875022 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt @@ -0,0 +1,151 @@ +package de.psdev.devdrawer.profiles.ui.list + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import 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.loading.LoadingView +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import kotlinx.coroutines.launch + +@Composable +fun WidgetProfilesScreen( + viewModel: WidgetProfilesViewModel = hiltViewModel(), + editProfile: (WidgetProfile) -> Unit = {} +) { + val coroutineScope = rememberCoroutineScope() + var deleteDialogShown by remember { mutableStateOf(DeleteDialogState.Hidden) } + val viewState by viewModel.viewState.collectAsState() + WidgetProfileListScreen( + viewState = viewState, + onWidgetProfileClick = editProfile, + onWidgetProfileLongClick = { widgetProfile -> + coroutineScope.launch { + val widgets = viewModel.devDrawerDatabase.widgetDao().findAllByProfileId(widgetProfile.id) + deleteDialogShown = if (widgets.isNotEmpty()) { + DeleteDialogState.InUseError( + widgetProfile = widgetProfile, + widgets = widgets + ) + } else { + DeleteDialogState.Showing(widgetProfile) + } + } + }, + onCreateWidgetProfileClick = { + coroutineScope.launch { + val widgetProfile = viewModel.createNewProfile() + editProfile(widgetProfile) + } + } + ) + when (val state = deleteDialogShown) { + DeleteDialogState.Hidden -> Unit + is DeleteDialogState.Showing -> AlertDialog( + onDismissRequest = { }, + title = { + Text(text = "Confirm") + }, + text = { + Text(text = "Do you really want to delete the profile '${state.widgetProfile.name}'?") + }, + confirmButton = { + TextButton(onClick = { + coroutineScope.launch { + viewModel.deleteProfile(state.widgetProfile) + deleteDialogShown = DeleteDialogState.Hidden + } + }) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { + deleteDialogShown = DeleteDialogState.Hidden + }) { + Text("Cancel") + } + } + ) + is DeleteDialogState.InUseError -> { + WidgetInUseErrorAlertDialog(state, onDismiss = { + deleteDialogShown = DeleteDialogState.Hidden + }) + } + } +} + +@Composable +fun WidgetProfileListScreen( + viewState: WidgetProfilesViewModel.ViewState, + onWidgetProfileClick: (WidgetProfile) -> Unit = {}, + onWidgetProfileLongClick: (WidgetProfile) -> Unit = {}, + onCreateWidgetProfileClick: () -> Unit = {} +) { + when (viewState) { + WidgetProfilesViewModel.ViewState.Loading -> LoadingView() + is WidgetProfilesViewModel.ViewState.Loaded -> { + val profiles = viewState.data + if (profiles.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + color = MaterialTheme.colors.onBackground, + text = stringResource(id = R.string.no_profiles) + ) + Spacer(modifier = Modifier.size(16.dp)) + Button(onClick = onCreateWidgetProfileClick) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(id = R.string.widget_profile_list_create_new) + ) + Text(text = stringResource(id = R.string.widget_profile_list_create_new)) + } + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + WidgetProfileList( + widgetProfiles = profiles, + onWidgetProfileClick = onWidgetProfileClick, + onWidgetProfileLongClick = onWidgetProfileLongClick + ) + FloatingActionButton( + onClick = onCreateWidgetProfileClick, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 16.dp) + ) { + Icon(imageVector = Icons.Outlined.Add, contentDescription = stringResource(id = R.string.widget_profile_list_create_new)) + } + } + } + } + } + +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetProfileListScreen_Empty() { + DevDrawerTheme { + WidgetProfileListScreen(viewState = WidgetProfilesViewModel.ViewState.Loaded(emptyList())) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt b/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt new file mode 100644 index 00000000..be09343a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt @@ -0,0 +1,38 @@ +package de.psdev.devdrawer.receivers + +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import de.psdev.devdrawer.widgets.SaveWidgetWorker +import mu.KLogging + +class PinWidgetSuccessReceiver : BroadcastReceiver() { + + companion object : KLogging() { + fun intent(context: Context): Intent = Intent(context, PinWidgetSuccessReceiver::class.java) + } + + override fun onReceive(context: Context, intent: Intent) { + logger.warn { "onReceive[context=$context, intent=$intent]" } + val widgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { + val inputData = Data.Builder().putInt(SaveWidgetWorker.ARG_WIDGET_ID, widgetId).build() + val request = OneTimeWorkRequestBuilder() + .setInputData(inputData) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + "SAVE_WIDGET_$widgetId", + ExistingWorkPolicy.REPLACE, + request + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/settings/ListPreference.kt b/app/src/main/java/de/psdev/devdrawer/settings/ListPreference.kt new file mode 100644 index 00000000..f486c97c --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/ListPreference.kt @@ -0,0 +1,130 @@ +package de.psdev.devdrawer.settings + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.SortOrder +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun ListPreference( + label: String, + values: Map, + currentValue: T, + dialogTitle: String = "Select option", + onClick: (T) -> Unit = {} +) { + var selectionDialog by remember { + mutableStateOf(false) + } + require(currentValue in values.keys) { "currentValue needs to be a key in values" } + Column( + modifier = Modifier + .defaultMinSize(minHeight = 64.dp) + .fillMaxWidth() + .padding(8.dp) + .clickable { selectionDialog = true } + ) { + Text(style = MaterialTheme.typography.body1, color = MaterialTheme.colors.primary, text = label) + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + Text(style = MaterialTheme.typography.subtitle1, color = MaterialTheme.colors.primaryVariant, text = requireNotNull(values[currentValue])) + } + if (selectionDialog) { + var selection by remember { mutableStateOf(currentValue) } + AlertDialog( + onDismissRequest = { selectionDialog = false }, + title = { Text(text = dialogTitle) }, + text = { + LazyColumn( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + ) { + val list: List = values.keys.toList() + items(list) { item -> + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = (item == selection), + onClick = { selection = item } + ) + .padding(8.dp) + + ) { + RadioButton( + selected = selection == item, + onClick = { selection = item } + ) + Text( + text = values[item].orEmpty(), + style = MaterialTheme.typography.body1.merge(), + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + }, + dismissButton = { + TextButton(onClick = { selectionDialog = false }) { + Text(text = stringResource(id = R.string.cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + selectionDialog = false + onClick(selection) + }) { + Text(text = stringResource(id = R.string.ok)) + } + } + ) + } + } +} + +@Preview(name = "Light Mode (Enabled)", showSystemUi = true) +@Preview(name = "Dark Mode (Enabled)", showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_SelectionPreference_Enabled() { + DevDrawerTheme { + Box(modifier = Modifier.fillMaxSize()) { + ListPreference( + label = "Setting 1", + values = mapOf( + SortOrder.LAST_UPDATED to "Last updated", + SortOrder.FIRST_INSTALLED to "First installed" + ), + currentValue = SortOrder.FIRST_INSTALLED + ) + } + } +} + +@Preview(name = "Light Mode (Disabled)", showSystemUi = true) +@Preview(name = "Dark Mode (Disabled)", showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_SelectionPreference_Disabled() { + DevDrawerTheme { + Box(modifier = Modifier.fillMaxSize()) { + ListPreference( + label = "Setting 1", + values = mapOf( + SortOrder.LAST_UPDATED to "Last updated", + SortOrder.FIRST_INSTALLED to "First installed" + ), + currentValue = SortOrder.FIRST_INSTALLED + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt b/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt index f03d2127..9a396c9e 100644 --- a/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt +++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt @@ -1,72 +1,32 @@ package de.psdev.devdrawer.settings -import android.appwidget.AppWidgetManager -import android.content.ComponentName import android.os.Bundle -import androidx.annotation.StringRes -import androidx.core.content.edit -import androidx.lifecycle.lifecycleScope -import androidx.preference.* +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.R -import de.psdev.devdrawer.analytics.TrackingService -import de.psdev.devdrawer.appwidget.DDWidgetProvider +import de.psdev.devdrawer.BaseFragment import de.psdev.devdrawer.config.RemoteConfigService -import java.util.* +import de.psdev.devdrawer.ui.theme.DevDrawerTheme import javax.inject.Inject @AndroidEntryPoint -class SettingsFragment: PreferenceFragmentCompat() { +class SettingsFragment: BaseFragment() { @Inject lateinit var remoteConfigService: RemoteConfigService - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.preferences) - - findPreference(R.string.pref_sort_order).apply { - summary = sortOrderLabelFromValue( - sharedPreferences.getString( - getString(R.string.pref_sort_order), - getString(R.string.pref_sort_order_default) - ).orEmpty() - ) - setOnPreferenceChangeListener { preference, newValue -> - sharedPreferences.edit { - putString(preference.key, newValue.toString()) - } - - preference.summary = sortOrderLabelFromValue(newValue.toString()) - - val appWidgetManager = AppWidgetManager.getInstance(context) - val appWidgetIds = - appWidgetManager.getAppWidgetIds(ComponentName(context, DDWidgetProvider::class.java)) - appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.listView) - - return@setOnPreferenceChangeListener true + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { + SettingsScreen() } } - val analyticsCategory = requireNotNull(findPreference("feature_analytics")) - val analyticsPreference = findPreference(R.string.pref_feature_analytics_opted_in) - lifecycleScope.launchWhenResumed { - val analyticsEnabled = remoteConfigService.getBoolean(TrackingService.CONFIG_KEY_ENABLED) - analyticsCategory.isVisible = analyticsEnabled - analyticsPreference.isVisible = analyticsEnabled - } - } - - // ========================================================================================================================== - // Private API - // ========================================================================================================================== - - private inline fun findPreference(@StringRes keyRes: Int): T = - requireNotNull(findPreference(getString(keyRes))) - - private fun sortOrderLabelFromValue(value: String): String { - val resources = resources - val values = resources.getStringArray(R.array.sort_order_values) - val names = resources.getStringArray(R.array.sort_order_labels) - return names[values.indexOfFirst { it == value }] } } \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt b/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt new file mode 100644 index 00000000..09083d4a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt @@ -0,0 +1,98 @@ +package de.psdev.devdrawer.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import 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() { + SettingsScreen( + viewModel = hiltViewModel() + ) +} + +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel +) { + val viewState by viewModel.viewState.collectAsState() + + SettingsScreen( + viewState = viewState, + onActivityChooserChanged = { + viewModel.onActivityChooserChanged(it) + }, + onSortOrderChanged = { + viewModel.onSortOrderChanged(it) + }, + onAnalyticsOptInChanged = { + viewModel.onAnalyticsOptInChanged(it) + } + + ) +} + +@Composable +fun SettingsScreen( + viewState: SettingsViewModel.ViewState, + onActivityChooserChanged: (Boolean) -> Unit = {}, + onSortOrderChanged: (SortOrder) -> Unit = {}, + onAnalyticsOptInChanged: (Boolean) -> Unit = {}, +) { + when (val state = viewState) { + SettingsViewModel.ViewState.Loading -> LoadingView() + is SettingsViewModel.ViewState.Loaded -> { + val settings = state.settings + Column { + SwitchPreference(text = stringResource(id = R.string.pref_show_activity_choice_title), enabled = settings.activityChooserEnabled) { + onActivityChooserChanged(it) + } + Divider() + val labels = stringArrayResource(id = R.array.sort_order_labels) + ListPreference( + label = stringResource(id = R.string.pref_sort_order_title), + values = SortOrder.values().mapIndexed { index, sortOrder -> + sortOrder to labels[index] + }.toMap(), + currentValue = settings.defaultSortOrder + ) { + onSortOrderChanged(it) + } + Divider() + AnimatedVisibility(visible = state.analyticsVisible) { + SwitchPreference(text = stringResource(id = R.string.pref_feature_analytics_opted_in_title), enabled = settings.analyticsOptIn) { + onAnalyticsOptInChanged(it) + } + } + } + } + } +} + +@Preview +@Composable +fun Preview_SettingsScreen() { + DevDrawerTheme { + SettingsScreen( + viewState = SettingsViewModel.ViewState.Loaded( + settings = SettingsViewModel.Settings( + activityChooserEnabled = true, + defaultSortOrder = SortOrder.LAST_UPDATED, + analyticsOptIn = true + ), + analyticsVisible = true + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SettingsViewModel.kt b/app/src/main/java/de/psdev/devdrawer/settings/SettingsViewModel.kt new file mode 100644 index 00000000..8477bb0b --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsViewModel.kt @@ -0,0 +1,124 @@ +package de.psdev.devdrawer.settings + +import android.app.Application +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.analytics.TrackingService +import de.psdev.devdrawer.appwidget.SortOrder +import de.psdev.devdrawer.config.RemoteConfigService +import de.psdev.devdrawer.receivers.UpdateReceiver +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import mu.KLogging +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val application: Application, + private val remoteConfigService: RemoteConfigService, + private val sharedPreferences: SharedPreferences +): ViewModel() { + companion object: KLogging() + + val persistedSettings = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences: SharedPreferences, _: String -> + trySendBlocking(sharedPreferences.loadSettings(application)) + } + send(sharedPreferences.loadSettings(application)) + sharedPreferences.registerOnSharedPreferenceChangeListener(listener) + awaitClose { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) + } + } + + val viewState: MutableStateFlow = MutableStateFlow(ViewState.Loading) + + init { + viewModelScope.launch { + // TODO Convert to Flow + val analyticsEnabled = remoteConfigService.getBoolean(TrackingService.CONFIG_KEY_ENABLED) + settingsFlow().collect { settings -> + viewState.value = ViewState.Loaded( + analyticsVisible = analyticsEnabled, + settings = settings + ) + } + } + } + + fun onActivityChooserChanged(enabled: Boolean) { + sharedPreferences.edit { + putBoolean(application.getString(R.string.pref_show_activity_choice), enabled) + } + onSettingsUpdated() + } + + fun onSortOrderChanged(sortOrder: SortOrder) { + sharedPreferences.edit { + putString(application.getString(R.string.pref_sort_order), sortOrder.name) + } + onSettingsUpdated() + } + + fun onAnalyticsOptInChanged(enabled: Boolean) { + sharedPreferences.edit { + putBoolean(application.getString(R.string.pref_feature_analytics_opted_in), enabled) + } + onSettingsUpdated() + } + + // ========================================================================================================================== + // Private API + // ========================================================================================================================== + + private fun settingsFlow(): Flow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences: SharedPreferences, key: String -> + logger.warn { "Setting updated: $key" } + trySendBlocking(sharedPreferences.loadSettings(application)) + } + send(sharedPreferences.loadSettings(application)) + sharedPreferences.registerOnSharedPreferenceChangeListener(listener) + awaitClose { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) + } + } + + private fun onSettingsUpdated() { + UpdateReceiver.send(application) + } + + sealed class ViewState { + object Loading: ViewState() + data class Loaded( + val analyticsVisible: Boolean, + val settings: Settings + ): ViewState() + } + + data class Settings( + val activityChooserEnabled: Boolean, + val defaultSortOrder: SortOrder, + val analyticsOptIn: Boolean + ) + + private fun SharedPreferences.loadSettings(application: Application): Settings = Settings( + activityChooserEnabled = getBoolean( + application.getString(R.string.pref_show_activity_choice), + application.resources.getBoolean(R.bool.pref_show_activity_choice_default) + ), + defaultSortOrder = SortOrder.valueOf( + getString(application.resources.getString(R.string.pref_sort_order), null) ?: application.getString(R.string.pref_sort_order_default) + ), + analyticsOptIn = getBoolean(application.getString(R.string.pref_feature_analytics_opted_in), false) + ) + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SwitchPreference.kt b/app/src/main/java/de/psdev/devdrawer/settings/SwitchPreference.kt new file mode 100644 index 00000000..acf53efa --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/SwitchPreference.kt @@ -0,0 +1,50 @@ +package de.psdev.devdrawer.settings + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun SwitchPreference( + text: String, + enabled: Boolean, + onChange: (Boolean) -> Unit = {} +) { + Row( + modifier = Modifier + .defaultMinSize(minHeight = 64.dp) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(modifier = Modifier.weight(1f), color = MaterialTheme.colors.primary, text = text) + Switch(checked = enabled, onCheckedChange = onChange) + } +} + +@Preview(name = "Light Mode (Enabled)") +@Preview(name = "Dark Mode (Enabled)", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_SwitchPreference_Enabled() { + DevDrawerTheme { + SwitchPreference(text = "Test", enabled = true) + } +} + +@Preview(name = "Light Mode (Disabled)") +@Preview(name = "Dark Mode (Disabled)", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_SwitchPreference_Disabled() { + DevDrawerTheme { + SwitchPreference(text = "Test", enabled = false) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt b/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt new file mode 100644 index 00000000..76141689 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt @@ -0,0 +1,149 @@ +package de.psdev.devdrawer.ui.autocomplete + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import de.psdev.devdrawer.R +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import kotlin.math.roundToInt + +@Composable +fun AutoCompleteTextView( + modifier: Modifier = Modifier, + options: List, + label: @Composable (() -> Unit)? = null, + onTextChanged: (String) -> Unit = {} +) { + val interactionSource = remember { MutableInteractionSource() } + val textFieldFocused by interactionSource.collectIsFocusedAsState() + var text by remember { mutableStateOf("") } + var textPosition by remember { + mutableStateOf(TextViewPositionData()) + } + SelectionContainer { + OutlinedTextField( + modifier = modifier + .fillMaxWidth() + .onGloballyPositioned { + textPosition = TextViewPositionData( + positionInWindow = it.positionInWindow(), + positionInParent = it.positionInParent(), + size = it.size + ) + }, + value = text, + trailingIcon = { + if (text.isNotBlank()) { + Icon( + // TODO Fix size and clipping + modifier = Modifier + .clip(MaterialTheme.shapes.small.copy(CornerSize(percent = 50))) + .clickable { + text = "" + onTextChanged(text) + }, + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(id = R.string.clear) + ) + } + }, + interactionSource = interactionSource, + onValueChange = { + text = it + onTextChanged(text) + }, + label = label, + keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Ascii) + ) + } + if (textFieldFocused) { + val textFieldStart = textPosition.positionInParent.x.roundToInt() + val textFieldBottom = (textPosition.positionInParent.y + textPosition.size.height).roundToInt() + val popupOffset = IntOffset(textFieldStart, textFieldBottom) + val popupWidth = with(LocalDensity.current) { textPosition.size.width.toDp() } + val bottom = LocalView.current.height + val popupHeightMax = with(LocalDensity.current) { (bottom - textFieldBottom).toDp() } + Popup( + offset = popupOffset, + ) { + Surface( + modifier = Modifier + .requiredWidth(popupWidth) + .heightIn(max = popupHeightMax), + elevation = 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/loading/LoadingView.kt b/app/src/main/java/de/psdev/devdrawer/ui/loading/LoadingView.kt new file mode 100644 index 00000000..50fb709f --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/loading/LoadingView.kt @@ -0,0 +1,46 @@ +package de.psdev.devdrawer.ui.loading + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.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, + 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.colors.background) { + LoadingView() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/ui/theme/Color.kt b/app/src/main/java/de/psdev/devdrawer/ui/theme/Color.kt new file mode 100644 index 00000000..99e85a57 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package de.psdev.devdrawer.ui.theme + +import androidx.compose.ui.graphics.Color + +val LightGreen500 = Color(0xFF8BC34A) +val LightGreen700 = Color(0xFF689F38) +val LightGreen200 = Color(0xFFC5E1A5) + +val Orange500 = Color(0xFFff9800) +val Orange700 = Color(0xFFf57c00) +val Orange200 = Color(0xFFffcc80) \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/ui/theme/Shape.kt b/app/src/main/java/de/psdev/devdrawer/ui/theme/Shape.kt new file mode 100644 index 00000000..cfb1dfdd --- /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.material.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..10ff32c6 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/Theme.kt @@ -0,0 +1,47 @@ +package de.psdev.devdrawer.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorPalette = darkColors( + primary = LightGreen500, + primaryVariant = LightGreen200, + secondary = Orange700, + background = Color(0xFF575757), + surface = Color(0xFF575757), + onPrimary = Color.White, + onSecondary = Color.Black, + onBackground = Color.White, + onSurface = Color.White, + onError = Color.Black +) + +private val LightColorPalette = lightColors( + primary = LightGreen500, + primaryVariant = LightGreen200, + secondary = Orange700, + surface = Color(0xFFDDDDDD) +) + +@Composable +fun DevDrawerTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette + } + + MaterialTheme( + colors = colors, +// typography = Typography, +// shapes = Shapes, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/ui/theme/Type.kt b/app/src/main/java/de/psdev/devdrawer/ui/theme/Type.kt new file mode 100644 index 00000000..257bab10 --- /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.material.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( + body1 = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ) + /* Other default text styles to override + button = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + fontSize = 14.sp + ), + caption = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/utils/Constants.kt b/app/src/main/java/de/psdev/devdrawer/utils/Constants.kt index 592097a5..db8ec998 100644 --- a/app/src/main/java/de/psdev/devdrawer/utils/Constants.kt +++ b/app/src/main/java/de/psdev/devdrawer/utils/Constants.kt @@ -1,8 +1,6 @@ package de.psdev.devdrawer.utils object Constants { - const val ACTION_WIDGET_PINNED = "de.psdev.devdrawer.WIDGET_PINNED" - const val LAUNCH_APP = 1 const val LAUNCH_APP_DETAILS = 2 const val LAUNCH_UNINSTALL = 3 diff --git a/app/src/main/java/de/psdev/devdrawer/utils/Flow.kt b/app/src/main/java/de/psdev/devdrawer/utils/Flow.kt new file mode 100644 index 00000000..5d38cd1e --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/utils/Flow.kt @@ -0,0 +1,20 @@ +package de.psdev.devdrawer.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import kotlinx.coroutines.flow.Flow + +@Composable +fun rememberFlowWithLifecycle( + flow: Flow, + lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED +): Flow = remember(flow, lifecycle) { + flow.flowWithLifecycle( + lifecycle = lifecycle, + minActiveState = minActiveState + ) +} \ 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 index 6669454c..97cb6399 100644 --- a/app/src/main/java/de/psdev/devdrawer/utils/PackageManagerKt.kt +++ b/app/src/main/java/de/psdev/devdrawer/utils/PackageManagerKt.kt @@ -15,15 +15,15 @@ fun PackageManager.getExistingPackages(): List { val appSet = mutableSetOf() activities.forEach { resolveInfo -> - var appName = resolveInfo.activityInfo.applicationInfo.packageName - appSet.add(appName) - while (appName.isNotEmpty()) { - val lastIndex = appName.lastIndexOf(".") + var packageName = resolveInfo.activityInfo.applicationInfo.packageName + appSet.add(packageName) + while (packageName.isNotEmpty()) { + val lastIndex = packageName.lastIndexOf(".") if (lastIndex > 0) { - appName = appName.substring(0, lastIndex) - appSet.add(appName + ".*") + packageName = packageName.substring(0, lastIndex) + appSet.add("$packageName.*") } else { - appName = "" + packageName = "" } } } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/CleanupWidgetsWorker.kt b/app/src/main/java/de/psdev/devdrawer/widgets/CleanupWidgetsWorker.kt index 7e6f5996..aabbf696 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/CleanupWidgetsWorker.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/CleanupWidgetsWorker.kt @@ -28,9 +28,17 @@ class CleanupWidgetsWorker @AssistedInject constructor( fun enableWorker(application: Application) { val workManager = WorkManager.getInstance(application) - val request = + + workManager.enqueueUniqueWork( + TAG, + ExistingWorkPolicy.APPEND_OR_REPLACE, + OneTimeWorkRequestBuilder().build() + ) + workManager.enqueueUniquePeriodicWork( + TAG, + ExistingPeriodicWorkPolicy.REPLACE, PeriodicWorkRequestBuilder(30, TimeUnit.MINUTES).build() - workManager.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) + ) } } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragment.kt deleted file mode 100644 index 74561804..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragment.kt +++ /dev/null @@ -1,201 +0,0 @@ -package de.psdev.devdrawer.widgets - -import android.app.Activity -import android.appwidget.AppWidgetManager -import android.content.Intent -import android.graphics.Color -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import com.github.dhaval2404.colorpicker.MaterialColorPickerDialog -import com.github.dhaval2404.colorpicker.model.ColorShape -import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.BaseFragment -import de.psdev.devdrawer.R -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.WidgetProfile -import de.psdev.devdrawer.databinding.FragmentWidgetEditBinding -import de.psdev.devdrawer.profiles.WidgetProfilesDetailsLookup -import de.psdev.devdrawer.profiles.WidgetProfilesItemKeyProvider -import de.psdev.devdrawer.profiles.WidgetProfilesListAdapter -import de.psdev.devdrawer.receivers.UpdateReceiver -import de.psdev.devdrawer.utils.awaitSubmit -import de.psdev.devdrawer.utils.receiveClicksFrom -import de.psdev.devdrawer.utils.receiveTextChangesFrom -import de.psdev.devdrawer.utils.sortColorList -import de.psdev.devdrawer.widgets.EditWidgetFragmentViewModel.Selection -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import javax.inject.Inject - -@AndroidEntryPoint -class EditWidgetFragment : BaseFragment() { - - // Dependencies - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - @Inject - lateinit var viewModelViewModelFactory: EditWidgetFragmentViewModel.ViewModelFactory - - val args by navArgs() - - val viewModel: EditWidgetFragmentViewModel by viewModels { - EditWidgetFragmentViewModel.factory(viewModelViewModelFactory, args.widgetId) - } - - var _selectionTracker: SelectionTracker? = null - - override fun createViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentWidgetEditBinding = - FragmentWidgetEditBinding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val adapter = WidgetProfilesListAdapter() - adapter.itemLongClickListener = { widgetProfile -> - findNavController().navigate(EditWidgetFragmentDirections.createProfileAction(widgetProfile.id)) - } - // Setup views - with(binding) { - with(editName) { - setText("Widget ${args.widgetId}") - } - with(btnColor) { - setOnClickListener { - val currentColor = viewModel.savedWidget.value?.color ?: Color.BLACK - MaterialColorPickerDialog - .Builder(requireContext()) - .setTitle(R.string.pick_widget_color) - .setDefaultColor(currentColor) - .setColorShape(ColorShape.SQAURE) - .setColorRes(resources.getIntArray(R.array.widget_colors).sortColorList()) - .setPositiveButton(R.string.ok) - .setNegativeButton(R.string.cancel) - .setColorListener { color, _ -> - setBackgroundColor(color) - viewModel.inputColor.value = color - } - .showBottomSheet(childFragmentManager) - } - } - } - lifecycleScope.launchWhenResumed { - with(binding) { - val widget = checkNotNull(viewModel.savedWidget.filterNotNull().first()) - editName.setText(widget.name) - btnColor.setBackgroundColor(widget.color) - } - } - - binding.btnNewProfile.setOnClickListener { - lifecycleScope.launchWhenResumed { - val widgetProfile = WidgetProfile(name = "Profile for ${viewModel.inputWidgetName.value}") - devDrawerDatabase.widgetProfileDao().insert(widgetProfile) - findNavController().navigate(EditWidgetFragmentDirections.createProfileAction(widgetProfile.id)) - } - } - - binding.recyclerProfiles.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false) - binding.recyclerProfiles.adapter = adapter - val selectionTracker = SelectionTracker.Builder( - "widgetProfile", - binding.recyclerProfiles, - WidgetProfilesItemKeyProvider(adapter), - WidgetProfilesDetailsLookup(binding.recyclerProfiles), - StorageStrategy.createStringStorage() - ).withSelectionPredicate( - SelectionPredicates.createSelectSingleAnything() - ).build().also { - it.onRestoreInstanceState(savedInstanceState) - if (savedInstanceState == null) { - lifecycleScope.launchWhenResumed { - it.select(devDrawerDatabase.widgetDao().findById(args.widgetId)?.profileId.orEmpty()) - } - } - _selectionTracker = it - } - adapter.selectionTracker = selectionTracker - - viewLifecycleScope.launch { - viewModel.inputWidgetName.receiveTextChangesFrom(binding.editName).launchIn(this) - - selectionTracker.addObserver(object : SelectionTracker.SelectionObserver() { - override fun onSelectionChanged() { - super.onSelectionChanged() - val widgetProfile = selectionTracker.selection.asSequence() - .map { selectedKey -> adapter.currentList.firstOrNull { it.id == selectedKey } }.firstOrNull() - if (widgetProfile != null) { - viewModel.inputSelectedProfile.value = Selection.Profile(widgetProfile) - } else { - viewModel.inputSelectedProfile.value = Selection.Nothing - } - } - }) - viewModel.inputSaveTrigger.receiveClicksFrom(binding.btnConfirm).launchIn(this) - viewModel.outputWidgetProfiles.onEach { - adapter.awaitSubmit(it) - binding.txtNoProfiles.isVisible = it.isEmpty() - }.launchIn(this) - viewModel.outputFormCompleted.onEach { completed -> - if (completed) { - with(binding.btnConfirm) { - isEnabled = true - setText(R.string.save) - } - } else { - with(binding.btnConfirm) { - isEnabled = false - text = "Select profile" - } - } - }.launchIn(this) - viewModel.outputCloseTrigger.onEach { widget -> - val resultValue = Intent().apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget.id) - } - requireActivity().setResult(Activity.RESULT_OK, resultValue) - // Will either close the fragment or finish the activity when it's the last activity - if (!findNavController().popBackStack()) { - requireActivity().finish() - } - UpdateReceiver.send(requireContext()) - }.launchIn(this) - } - } - - override fun onResume() { - super.onResume() - updateToolbarTitle(R.string.edit_widget) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - _selectionTracker?.onSaveInstanceState(outState) - } - - override fun onDestroyView() { - binding.recyclerProfiles.adapter = null - super.onDestroyView() - } - - // TODO Default name: Widget - // TODO After losing focus of text input update name in viewState - -} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/SaveWidgetWorker.kt b/app/src/main/java/de/psdev/devdrawer/widgets/SaveWidgetWorker.kt new file mode 100644 index 00000000..5f8ecec4 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/SaveWidgetWorker.kt @@ -0,0 +1,49 @@ +package de.psdev.devdrawer.widgets + +import android.content.Context +import android.graphics.Color +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.receivers.UpdateReceiver +import mu.KLogging + +@HiltWorker +class SaveWidgetWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val database: DevDrawerDatabase +) : CoroutineWorker(appContext, params) { + + companion object : KLogging() { + const val ARG_WIDGET_ID = "widgetId" + const val INVALID_WIDGET_ID = -1 + } + + override suspend fun doWork(): Result { + val widgetId = inputData.getInt(ARG_WIDGET_ID, INVALID_WIDGET_ID) + check(widgetId != INVALID_WIDGET_ID) { "Invalid widget ID" } + val widgetDao = database.widgetDao() + val widgetProfileDao = database.widgetProfileDao() + val defaultWidgetProfile = widgetProfileDao.findAll().firstOrNull() + ?: WidgetProfile(name = "Default").also { + widgetProfileDao.insert(it) + } + + // Create entries in database + val widget = Widget( + id = widgetId, + name = "Widget $widgetId", + color = Color.BLACK, + profileId = defaultWidgetProfile.id + ) + widgetDao.insert(widget) + UpdateReceiver.send(applicationContext) + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetListFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetListFragment.kt deleted file mode 100644 index e4aafdab..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetListFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -package de.psdev.devdrawer.widgets - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.content.ComponentName -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.RequiresApi -import androidx.core.content.getSystemService -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.BaseFragment -import de.psdev.devdrawer.R -import de.psdev.devdrawer.appwidget.DDWidgetProvider -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.databinding.FragmentWidgetListBinding -import de.psdev.devdrawer.utils.Constants -import de.psdev.devdrawer.utils.awaitSubmit -import de.psdev.devdrawer.utils.supportsVersion -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import mu.KLogging -import javax.inject.Inject - - -@AndroidEntryPoint -class WidgetListFragment : BaseFragment() { - - companion object : KLogging() - - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - override fun createViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentWidgetListBinding = FragmentWidgetListBinding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val clickListener: (Widget) -> Unit = { widget -> - findNavController().navigate(WidgetListFragmentDirections.editWidget(widget.id)) - } - val listAdapter = WidgetsListAdapter(clickListener) - with(binding) { - recyclerWidgets.adapter = listAdapter - } - devDrawerDatabase.widgetDao().findAllFlow().onEach { - listAdapter.awaitSubmit(it) - binding.containerNoWidgets.isVisible = it.isEmpty() - supportsVersion(Build.VERSION_CODES.O) { - with(binding.btnAddWidget) { - isVisible = true - setOnClickListener { - requestAppWidgetPinning() - } - } - } - }.launchIn(lifecycleScope) - } - - override fun onResume() { - super.onResume() - updateToolbarTitle(R.string.widgets) - } - - override fun onDestroyView() { - binding.recyclerWidgets.adapter = null - super.onDestroyView() - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun requestAppWidgetPinning() { - val activity = requireActivity() - val appWidgetManager: AppWidgetManager = activity.getSystemService() ?: return - val widgetProvider = ComponentName(activity, DDWidgetProvider::class.java) - if (appWidgetManager.isRequestPinAppWidgetSupported) { - val pinnedWidgetCallbackIntent = Intent(activity, DDWidgetProvider::class.java).apply { - action = Constants.ACTION_WIDGET_PINNED - } - val successCallback = PendingIntent.getBroadcast( - activity, - 1, - pinnedWidgetCallbackIntent, - PendingIntent.FLAG_UPDATE_CURRENT - ) - val bundle = bundleOf() - appWidgetManager.requestPinAppWidget(widgetProvider, bundle, successCallback) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt new file mode 100644 index 00000000..f513e230 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt @@ -0,0 +1,23 @@ +package de.psdev.devdrawer.widgets + +import android.app.Application +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.receivers.UpdateReceiver +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WidgetRepository @Inject constructor( + private val application: Application, + private val devDrawerDatabase: DevDrawerDatabase +) { + + fun widgetFlow(widgetId: Int) = devDrawerDatabase.widgetDao().widgetWithIdObservable(widgetId) + + suspend fun update(widget: Widget) { + devDrawerDatabase.widgetDao().update(widget) + UpdateReceiver.send(application) + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetsListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetsListAdapter.kt deleted file mode 100644 index 50480379..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetsListAdapter.kt +++ /dev/null @@ -1,46 +0,0 @@ -package de.psdev.devdrawer.widgets - -import android.view.ViewGroup -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.databinding.ListItemWidgetBinding -import de.psdev.devdrawer.utils.layoutInflater -import mu.KLogging - -class WidgetsListAdapter( - private val clickListener: (Widget) -> Unit -): ListAdapter(Widget.DIFF_CALLBACK) { - - companion object: KLogging() - - // ========================================================================================================================== - // RecyclerView.Adapter - // ========================================================================================================================== - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WidgetsListViewHolder = - WidgetsListViewHolder( - ListItemWidgetBinding.inflate(parent.layoutInflater, parent, false), - clickListener - ) - - override fun onBindViewHolder(holder: WidgetsListViewHolder, position: Int) { - holder.bindTo(getItem(position)) - } - - // ========================================================================================================================== - // WidgetsListViewHolder - // ========================================================================================================================== - - class WidgetsListViewHolder( - private val binding: ListItemWidgetBinding, - private val clickListener: (Widget) -> Unit - ): RecyclerView.ViewHolder(binding.root) { - - fun bindTo(widget: Widget) { - binding.txtName.text = widget.name - itemView.setOnClickListener { clickListener(widget) } - } - } -} - diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt new file mode 100644 index 00000000..318c214d --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt @@ -0,0 +1,62 @@ +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.material.* +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.body1, + text = widget.name + ) + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + Text( + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.body2, + text = stringResource(id = R.string.widget_id_template, widget.id) + ) + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetCard() { + DevDrawerTheme { + WidgetCard(widget = Widget(1, "Test Widget", Color.BLACK, "")) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetConfigActivity.kt similarity index 89% rename from app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt rename to app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetConfigActivity.kt index 278e2157..76fb7581 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetConfigActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetConfigActivity.kt @@ -1,4 +1,4 @@ -package de.psdev.devdrawer.widgets +package de.psdev.devdrawer.widgets.ui import android.app.Activity import android.appwidget.AppWidgetManager @@ -14,13 +14,14 @@ import de.psdev.devdrawer.R import de.psdev.devdrawer.analytics.Events import de.psdev.devdrawer.database.DevDrawerDatabase import de.psdev.devdrawer.databinding.ActivityWidgetConfigBinding +import de.psdev.devdrawer.widgets.ui.editor.WidgetEditFragmentArgs import mu.KLogging import javax.inject.Inject @AndroidEntryPoint -class WidgetConfigActivity : BaseActivity() { +class WidgetConfigActivity: BaseActivity() { - companion object : KLogging() { + companion object: KLogging() { fun createStartIntent(context: Context, appWidgetId: Int): Intent = Intent(context, WidgetConfigActivity::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) @@ -57,7 +58,10 @@ class WidgetConfigActivity : BaseActivity() { setSupportActionBar(binding.toolbar) val navController = findNavController(R.id.nav_host_fragment) - navController.setGraph(R.navigation.nav_config_widget, EditWidgetFragmentArgs(widgetId).toBundle()) + navController.setGraph( + R.navigation.nav_config_widget, + WidgetEditFragmentArgs.Builder(widgetId).build().toBundle() + ) val appBarConfiguration = AppBarConfiguration(navController.graph) binding.toolbar.setupWithNavController(navController, appBarConfiguration) } 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..91c236f4 --- /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.GridCells +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.foundation.lazy.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(), + cells = 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..110674d3 --- /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.material.AlertDialog +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import de.psdev.devdrawer.R +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun ColorSelectionDialog( + initialColor: Int, + onColorSelectionChanged: (Int) -> Unit = {}, + onColorSelected: (Int) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + var selectedColor by remember { mutableStateOf(initialColor) } + AlertDialog( + onDismissRequest = { }, + title = { + Text(text = "Select color") + }, + text = { + ColorGrid( + initialColor = initialColor, + onColorClicked = { + selectedColor = it + onColorSelectionChanged(it) + } + ) + }, + dismissButton = { + TextButton(onClick = { + onDismiss() + }) { + Text(stringResource(id = R.string.cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + onColorSelected(selectedColor) + }) { + Text(stringResource(id = R.string.apply)) + } + } + ) +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_ColorSelectionDialog() { + DevDrawerTheme { + Column { + ColorSelectionDialog( + initialColor = android.graphics.Color.BLACK + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/EditWidgetFragmentViewModel.kt similarity index 98% rename from app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt rename to app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/EditWidgetFragmentViewModel.kt index df82a4a4..69eeb427 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/EditWidgetFragmentViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/EditWidgetFragmentViewModel.kt @@ -1,4 +1,4 @@ -package de.psdev.devdrawer.widgets +package de.psdev.devdrawer.widgets.ui.editor import android.graphics.Color import androidx.lifecycle.ViewModel diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditFragment.kt new file mode 100644 index 00000000..cacaef51 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditFragment.kt @@ -0,0 +1,67 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import android.app.Activity +import android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID +import android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.navigation.findNavController +import com.google.accompanist.insets.ProvideWindowInsets +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.receivers.UpdateReceiver +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import javax.inject.Inject + +@AndroidEntryPoint +class WidgetEditFragment: BaseFragment() { + + // Dependencies + @Inject + lateinit var devDrawerDatabase: DevDrawerDatabase + + @Inject + lateinit var viewModelViewModelFactory: EditWidgetFragmentViewModel.ViewModelFactory + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + WidgetEditor( + onEditWidgetProfile = { widgetProfile -> + findNavController().navigate(WidgetEditFragmentDirections.createProfileAction(widgetProfile.id)) + }, + onChangesSaved = { + val widgetId = requireActivity().intent.getIntExtra(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) + val resultValue = Intent().apply { + putExtra(EXTRA_APPWIDGET_ID, widgetId) + } + requireActivity().setResult(Activity.RESULT_OK, resultValue) + // Will either close the fragment or finish the activity when it's the last activity + if (!findNavController().popBackStack()) { + requireActivity().finish() + } + UpdateReceiver.send(requireContext()) + } + ) + } + } + } + } + + override fun onResume() { + super.onResume() + updateToolbarTitle(R.string.edit_widget) + } + +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditor.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditor.kt new file mode 100644 index 00000000..5446ad6a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditor.kt @@ -0,0 +1,227 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import android.content.res.Configuration +import androidx.compose.animation.* +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Save +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import de.psdev.devdrawer.utils.rememberFlowWithLifecycle +import java.util.* + +@Composable +fun WidgetEditor( + onEditWidgetProfile: (WidgetProfile) -> Unit = {}, + onChangesSaved: (Widget) -> Unit = {} +) { + WidgetEditor( + viewModel = hiltViewModel(), + onEditWidgetProfile = onEditWidgetProfile, + onChangesSaved = onChangesSaved + ) +} + +@Composable +fun WidgetEditor( + viewModel: WidgetEditorViewModel, + onEditWidgetProfile: (WidgetProfile) -> Unit = {}, + onChangesSaved: (Widget) -> Unit = {} +) { + val viewState by rememberFlowWithLifecycle(viewModel.state) + .collectAsState(initial = WidgetEditorViewState.Empty) + + WidgetEditor( + viewState = viewState, + onNameChange = viewModel::onNameChanged, + onColorSelected = { color -> + viewModel.onWidgetColorChanged(color) + }, + onEditWidgetProfile = onEditWidgetProfile, + onWidgetProfileSelected = viewModel::onWidgetProfileSelected, + onSaveChangesClick = { + viewModel.saveChanges() + } + ) +} + +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class) +@Composable +fun WidgetEditor( + viewState: WidgetEditorViewState, + onNameChange: (String) -> Unit = {}, + onColorSelected: (Int) -> Unit = {}, + onEditWidgetProfile: (WidgetProfile) -> Unit = {}, + onWidgetProfileSelected: (WidgetProfile) -> Unit = {}, + onSaveChangesClick: () -> Unit = {} +) { + val widget = viewState.editableWidget + if (widget == null) { + // Loading + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + } + } else { + var dialogState by remember { mutableStateOf(WidgetEditorDialogsState.None) } + Box(modifier = Modifier.fillMaxSize()) { + Column { + Surface(modifier = Modifier.wrapContentHeight(), elevation = 2.dp) { + Column( + modifier = Modifier + .wrapContentHeight() + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.weight(1f), + singleLine = true, + value = widget.name, + onValueChange = onNameChange, + label = { Text(text = stringResource(id = R.string.name)) } + ) + ColorBox(isSelectedColor = true, color = widget.color) { + dialogState = WidgetEditorDialogsState.ColorSelection(widget.color) + } + } + } + } + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(viewState.widgetProfiles) { widgetProfile -> + val backgroundColor by animateColorAsState( + targetValue = if (widget.profileId == widgetProfile.id) MaterialTheme.colors.primary else MaterialTheme.colors.surface + ) + Card(backgroundColor = backgroundColor, 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) + } + } + } + } + } + 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 { + object None: WidgetEditorDialogsState() + data class ColorSelection( + val currentColor: Int + ): WidgetEditorDialogsState() +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetEditor_Loading() { + DevDrawerTheme { + WidgetEditor( + viewState = WidgetEditorViewState.Empty + ) + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetEditor_Loaded() { + val widgetProfile = WidgetProfile( + id = UUID.randomUUID().toString(), + name = "Test widget profile" + ) + val widget = Widget( + id = 1, + name = "Test widget", + color = android.graphics.Color.YELLOW, + profileId = widgetProfile.id + ) + DevDrawerTheme { + WidgetEditor( + viewState = WidgetEditorViewState( + persistedWidget = widget, + widgetProfiles = listOf(widgetProfile), + editableWidget = widget + ) + ) + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetEditor_Loaded_Changed() { + val widgetProfile = WidgetProfile( + id = UUID.randomUUID().toString(), + name = "Test widget profile" + ) + val widgetProfile2 = WidgetProfile( + id = UUID.randomUUID().toString(), + name = "Test widget profile 2" + ) + val widget = Widget( + id = 1, + name = "Test widget", + color = android.graphics.Color.YELLOW, + profileId = widgetProfile.id + ) + DevDrawerTheme { + WidgetEditor( + viewState = WidgetEditorViewState( + persistedWidget = widget, + widgetProfiles = listOf(widgetProfile, widgetProfile2), + editableWidget = widget.copy(profileId = widgetProfile2.id) + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt new file mode 100644 index 00000000..188846e8 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt @@ -0,0 +1,67 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.profiles.WidgetProfileRepository +import de.psdev.devdrawer.widgets.WidgetRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WidgetEditorViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val widgetRepository: WidgetRepository, + private val widgetProfileRepository: WidgetProfileRepository +): ViewModel() { + private val widgetId: Int = savedStateHandle["widgetId"]!! + + private val editableWidgetState: MutableStateFlow = MutableStateFlow(null) + + val state = combine( + widgetRepository.widgetFlow(widgetId), + widgetProfileRepository.widgetProfilesFlow(), + editableWidgetState + + ) { persistedWidget, widgetProfiles, editableWidget -> + if (editableWidgetState.value == null) { + editableWidgetState.value = persistedWidget + } + WidgetEditorViewState( + persistedWidget = persistedWidget, + widgetProfiles = widgetProfiles, + editableWidget = editableWidget + ) + } + + fun onNameChanged(newName: String) { + editableWidgetState.value = editableWidgetState.value?.copy( + name = newName + ) + } + + fun onWidgetColorChanged(color: Int) { + editableWidgetState.value = editableWidgetState.value?.copy( + color = color + ) + } + + fun onWidgetProfileSelected(widgetProfile: WidgetProfile) { + editableWidgetState.value = editableWidgetState.value?.copy( + profileId = widgetProfile.id + ) + } + + fun saveChanges() { + editableWidgetState.value?.let { + viewModelScope.launch { + widgetRepository.update(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt new file mode 100644 index 00000000..0f48e329 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt @@ -0,0 +1,16 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import androidx.compose.runtime.Immutable +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile + +@Immutable +data class WidgetEditorViewState( + val persistedWidget: Widget? = null, + val editableWidget: Widget? = null, + val widgetProfiles: List = emptyList() +) { + companion object { + val Empty = WidgetEditorViewState() + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt new file mode 100644 index 00000000..485b97b1 --- /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, + onWidgetClick: (Widget) -> Unit = {}, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp) +) { + LazyColumn( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight(), + contentPadding = contentPadding, + ) { + items(widgets, key = { it.id }) { widget -> + WidgetCard(widget = widget, onWidgetClick = onWidgetClick) + } + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetList() { + DevDrawerTheme { + WidgetList(widgets = testWidgets()) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListFragment.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListFragment.kt new file mode 100644 index 00000000..22ebb119 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListFragment.kt @@ -0,0 +1,74 @@ +package de.psdev.devdrawer.widgets.ui.list + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +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.compose.ui.platform.ComposeView +import androidx.core.content.getSystemService +import androidx.core.os.bundleOf +import androidx.navigation.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.receivers.PinWidgetSuccessReceiver +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import mu.KLogging +import javax.inject.Inject + +@AndroidEntryPoint +class WidgetListFragment: BaseFragment() { + + companion object: KLogging() + + @Inject + lateinit var devDrawerDatabase: DevDrawerDatabase + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { + WidgetListScreen( + navController = findNavController(), + onWidgetClick = { widget -> + findNavController().navigate(WidgetListFragmentDirections.editWidget(widget.id)) + }, + onRequestPinWidgetClick = ::requestAppWidgetPinning + ) + } + } + } + + override fun onResume() { + super.onResume() + updateToolbarTitle(R.string.widgets) + } + + @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 intent = PinWidgetSuccessReceiver.intent(activity) + val successCallback = PendingIntent.getBroadcast( + activity, + 1, + intent, + PendingIntent.FLAG_ONE_SHOT + ) + val bundle = bundleOf() + appWidgetManager.requestPinAppWidget(widgetProvider, bundle, successCallback) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt new file mode 100644 index 00000000..6ec8a613 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt @@ -0,0 +1,157 @@ +package de.psdev.devdrawer.widgets.ui.list + +import android.content.res.Configuration +import android.graphics.Color +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import java.util.* + +@Composable +fun WidgetListScreen( + navController: NavController, + widgetListScreenViewModel: WidgetListScreenViewModel = hiltViewModel(), + onWidgetClick: (Widget) -> Unit = {}, + onRequestPinWidgetClick: () -> Unit = {} +) { + val state by widgetListScreenViewModel.widgets + .onStart { delay(100L) } + .map { WidgetListScreenState.Loaded(it) } + .collectAsState(initial = WidgetListScreenState.Loading) + WidgetListScreen( + state = state, + onWidgetClick = onWidgetClick, + onRequestPinWidgetClick = onRequestPinWidgetClick + ) +} + +@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.colors.onBackground + ) + Spacer(modifier = Modifier.size(16.dp)) + Button(onClick = onRequestPinWidgetClick) { + Icon( + painter = painterResource(id = R.drawable.ic_outline_add_box_24), + contentDescription = "Add" + ) + 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) + ) + 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 { + object Loading: WidgetListScreenState() + data class Loaded( + val widgets: List + ): 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())) + } +} + +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..7d1cd2fd --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt @@ -0,0 +1,17 @@ +package de.psdev.devdrawer.widgets.ui.list + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.database.DevDrawerDatabase +import javax.inject.Inject + +@HiltViewModel +class WidgetListScreenViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val database: DevDrawerDatabase +): ViewModel() { + + val widgets = database.widgetDao().findAllFlow() + +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_widget_list.xml b/app/src/main/res/layout/fragment_widget_list.xml deleted file mode 100644 index 2e0c0e47..00000000 --- a/app/src/main/res/layout/fragment_widget_list.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_widget_profile_edit.xml b/app/src/main/res/layout/fragment_widget_profile_edit.xml deleted file mode 100644 index f767f9e0..00000000 --- a/app/src/main/res/layout/fragment_widget_profile_edit.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_widget_profile_list.xml b/app/src/main/res/layout/fragment_widget_profile_list.xml deleted file mode 100644 index 437e06d8..00000000 --- a/app/src/main/res/layout/fragment_widget_profile_list.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/list_item_package_filter.xml b/app/src/main/res/layout/list_item_package_filter.xml deleted file mode 100644 index 942bd821..00000000 --- a/app/src/main/res/layout/list_item_package_filter.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/list_item_widget.xml b/app/src/main/res/layout/list_item_widget.xml deleted file mode 100644 index 4adbe7d4..00000000 --- a/app/src/main/res/layout/list_item_widget.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_config_widget.xml b/app/src/main/res/navigation/nav_config_widget.xml index cfda4f96..f50ab9d4 100644 --- a/app/src/main/res/navigation/nav_config_widget.xml +++ b/app/src/main/res/navigation/nav_config_widget.xml @@ -8,7 +8,7 @@ + android:name="de.psdev.devdrawer.widgets.ui.list.WidgetListFragment"> + android:name="de.psdev.devdrawer.profiles.WidgetProfileListFragment"> @@ -32,7 +30,7 @@ tools:layout="@layout/fragment_about" /> DevDrawer2 - showActivityChoice + show_activity_choice false sort_order @@ -69,5 +69,23 @@ Cannot delete profile, still being used by widgets No Yes + ComposeActivity + + ID: %1$d + + + Create new profile + + + ID: %1$s + Loading… + + + Show activity choice on launch + Widget Sorting Options + Opt-in to analytics + Clear + Delete profile? + No apps available or all already added as filter 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 @@ + + + +