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..c18f9ba8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,11 +1,10 @@ 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' @@ -28,10 +27,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 +49,10 @@ 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" ] + useIR = true } testOptions { unitTests { @@ -105,6 +111,9 @@ android { exclude '**/NOTICE.txt' exclude '**/*.gwt.xml' } + composeOptions { + kotlinCompilerExtensionVersion Versions.androidXCompose + } } dependencies { @@ -136,10 +145,13 @@ dependencies { 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 +159,17 @@ dependencies { implementation Libs.androidx_room_ktx implementation Libs.androidx_work_runtime implementation Libs.androidx_work_gcm + implementation 'com.google.android.material:material:1.3.0' + implementation 'androidx.activity:activity-compose:1.3.0-beta02' + 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.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 +205,8 @@ dependencies { implementation Libs.kotlinCoroutinesAndroid // LeakCanary - debugImplementation Libs.leakCanary - implementation Libs.leakCanaryPlumberAndroid +// debugImplementation Libs.leakCanary +// implementation Libs.leakCanaryPlumberAndroid // Logging implementation Libs.slf4jAndroidLogger diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78d9bcdf..e2af559b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,7 +46,7 @@ android:taskAffinity="" android:theme="@style/AppTheme.Dialog.NoActionBar" /> - + @@ -65,21 +65,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/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/appwidget/DDWidgetProvider.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt index 5e5e9bf7..5450a151 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) @@ -126,8 +100,12 @@ class DDWidgetProvider : AppWidgetProvider() { 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_UPDATE_CURRENT + ) widgetView.setOnClickPendingIntent(R.id.btn_settings, configActivityPendingIntent) // Apps list 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/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..b23b48ff 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 @@ -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/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/WidgetEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetEditorViewModel.kt new file mode 100644 index 00000000..5bc1f332 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetEditorViewModel.kt @@ -0,0 +1,66 @@ +package de.psdev.devdrawer.profiles + +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.Widget +import de.psdev.devdrawer.database.WidgetProfile +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 WidgetEditorViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val database: DevDrawerDatabase +): ViewModel() { + private val widgetId: Int = savedStateHandle.get("widgetId")!! + + private val editableWidgetState: MutableStateFlow = MutableStateFlow(null) + + val state = combine( + database.widgetDao().widgetWithIdObservable(widgetId), + database.widgetProfileDao().findAllFlow().distinctUntilChanged(), + 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 { + database.widgetDao().update(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetEditorViewState.kt new file mode 100644 index 00000000..48bf58f2 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetEditorViewState.kt @@ -0,0 +1,16 @@ +package de.psdev.devdrawer.profiles + +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/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 index 2d3d1614..781dee96 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditFragment.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditFragment.kt @@ -1,173 +1,157 @@ package de.psdev.devdrawer.profiles -import android.database.sqlite.SQLiteConstraintException import android.os.Bundle -import android.view.* -import androidx.core.view.isVisible +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView 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.database.FilterType 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 de.psdev.devdrawer.ui.theme.DevDrawerTheme import mu.KLogging -import reactivecircus.flowbinding.android.view.clicks -import reactivecircus.flowbinding.android.widget.textChanges import javax.inject.Inject @AndroidEntryPoint -class WidgetProfileEditFragment : BaseFragment() { +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) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { + WidgetProfileEditor( + onAddPackageFilterClick = { + val directions = + WidgetProfileEditFragmentDirections.openAddPackageFilterBottomSheetDialogFragment(it.id) + findNavController().navigate(directions) + }, + onAddAppSignatureClick = { + val directions = + WidgetProfileEditFragmentDirections.openAppSignatureChooserBottomSheetDialogFragment(it.id) + findNavController().navigate(directions) + }, + onPackageFilterPreviewClick = { packageFilter -> + val directions = + WidgetProfileEditFragmentDirections.openFilterPreviewBottomSheetDialogFragment(packageFilter.id) + findNavController().navigate(directions) + }, + onPackageFilterInfoClick = { packageFilter -> + val text = when (packageFilter.type) { + FilterType.PACKAGE_NAME -> packageFilter.description + FilterType.SIGNATURE -> "SHA256: ${ + packageFilter.filter.uppercase().chunkedSequence(2) + .joinToString(separator = ":") + }" + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.info) + .setMessage(text) + .setPositiveButton(R.string.close, null) + .show() + }, + onDeletePackageFilterClick = { packageFilter -> + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Delete?") + .setNegativeButton(R.string.no) { _, _ -> } + .setPositiveButton(R.string.yes) { _, _ -> + lifecycleScope.launchWhenResumed { + devDrawerDatabase.packageFilterDao().deleteById(packageFilter.id) + UpdateReceiver.send(requireContext()) + } + } + .show() + } + ) } } - 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 onViewCreated(view: View, savedInstanceState: Bundle?) { +// super.onViewCreated(view, savedInstanceState) +// with(binding) { +// val context = requireContext() +// +// 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() - } +// 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/WidgetProfileEditor.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditor.kt new file mode 100644 index 00000000..2c7e90dc --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditor.kt @@ -0,0 +1,285 @@ +package de.psdev.devdrawer.profiles + +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.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 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 de.psdev.devdrawer.utils.rememberFlowWithLifecycle +import java.util.* + +@Composable +fun WidgetProfileEditor( + onAddPackageFilterClick: (WidgetProfile) -> Unit = {}, + onAddAppSignatureClick: (WidgetProfile) -> Unit = {}, + onPackageFilterPreviewClick: (PackageFilter) -> Unit = {}, + onPackageFilterInfoClick: (PackageFilter) -> Unit = {}, + onDeletePackageFilterClick: (PackageFilter) -> Unit = {} +) { + WidgetProfileEditor( + viewModel = hiltViewModel(), + onAddPackageFilterClick = onAddPackageFilterClick, + onAddAppSignatureClick = onAddAppSignatureClick, + onPackageFilterPreviewClick = onPackageFilterPreviewClick, + onPackageFilterInfoClick = onPackageFilterInfoClick, + onDeletePackageFilterClick = onDeletePackageFilterClick + ) +} + +@Composable +fun WidgetProfileEditor( + viewModel: WidgetProfileEditorViewModel, + onAddPackageFilterClick: (WidgetProfile) -> Unit = {}, + onAddAppSignatureClick: (WidgetProfile) -> Unit = {}, + onPackageFilterPreviewClick: (PackageFilter) -> Unit = {}, + onPackageFilterInfoClick: (PackageFilter) -> Unit = {}, + onDeletePackageFilterClick: (PackageFilter) -> Unit = {} +) { + val viewState by rememberFlowWithLifecycle(viewModel.state) + .collectAsState(initial = WidgetProfileEditorViewState.Empty) + + WidgetProfileEditor( + viewState = viewState, + onNameChange = { + viewModel.onNameChanged(it) + }, + onSaveNameClick = { + viewModel.saveChanges(viewState) + }, + onAddPackageFilterClick = onAddPackageFilterClick, + onAddAppSignatureClick = onAddAppSignatureClick, + onPackageFilterPreviewClick = onPackageFilterPreviewClick, + onPackageFilterInfoClick = onPackageFilterInfoClick, + onDeletePackageFilterClick = onDeletePackageFilterClick + ) +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +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.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + } + } else { + Column { + 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 = {} +) { + Surface { + 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/WidgetProfileEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditorViewModel.kt new file mode 100644 index 00000000..5b3ec359 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditorViewModel.kt @@ -0,0 +1,50 @@ +package de.psdev.devdrawer.profiles + +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 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 +) : 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 + ) + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditorViewState.kt new file mode 100644 index 00000000..d1310c36 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileEditorViewState.kt @@ -0,0 +1,16 @@ +package de.psdev.devdrawer.profiles + +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/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..9663fdab 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,111 @@ package de.psdev.devdrawer.profiles -import android.database.sqlite.SQLiteConstraintException import android.os.Bundle -import android.view.* +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.* +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource 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 androidx.navigation.findNavController 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 de.psdev.devdrawer.ui.theme.DevDrawerTheme import kotlinx.coroutines.launch 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 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() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { + val coroutineScope = rememberCoroutineScope() + var deleteDialogShown by remember { mutableStateOf(DeleteDialogState.Hidden) } + val widgetProfiles = devDrawerDatabase.widgetProfileDao().findAllFlow() + .collectAsState(initial = emptyList()) + WidgetProfileListScreen( + profiles = widgetProfiles.value, + onWidgetProfileClick = { widgetProfile -> + findNavController().navigate(WidgetProfileListFragmentDirections.editWidgetProfile(widgetProfile.id)) + }, + onWidgetProfileLongClick = { widgetProfile -> + coroutineScope.launch { + val widgets = devDrawerDatabase.widgetDao().findAllByProfileId(widgetProfile.id) + if (widgets.isNotEmpty()) { + deleteDialogShown = DeleteDialogState.InUseError( + widgetProfile = widgetProfile, + widgets = widgets + ) + } else { + deleteDialogShown = DeleteDialogState.Showing(widgetProfile) + } + } + }, + onCreateWidgetProfileClick = { + lifecycleScope.launch { + 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)) + } + } + ) + 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 { + devDrawerDatabase.widgetProfileDao().delete(state.widgetProfile) + deleteDialogShown = DeleteDialogState.Hidden + } + }) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { + deleteDialogShown = DeleteDialogState.Hidden + }) { + Text("Cancel") + } + } + ) + is DeleteDialogState.InUseError -> { + WidgetInUseErrorAlertDialog(state, onDismiss = { + deleteDialogShown = DeleteDialogState.Hidden + }) + } } - }) - _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 +113,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/WidgetProfilesListScreen.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListScreen.kt new file mode 100644 index 00000000..c0e0016a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesListScreen.kt @@ -0,0 +1,72 @@ +package de.psdev.devdrawer.profiles + +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.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.database.WidgetProfile +import de.psdev.devdrawer.ui.theme.DevDrawerTheme + +@Composable +fun WidgetProfileListScreen( + profiles: List, + onWidgetProfileClick: (WidgetProfile) -> Unit = {}, + onWidgetProfileLongClick: (WidgetProfile) -> Unit = {}, + onCreateWidgetProfileClick: () -> Unit = {} +) { + if (profiles.isEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + 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(profiles = 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/SettingsFragment.kt b/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt index f03d2127..a619a57a 100644 --- a/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt +++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsFragment.kt @@ -39,8 +39,7 @@ class SettingsFragment: PreferenceFragmentCompat() { preference.summary = sortOrderLabelFromValue(newValue.toString()) val appWidgetManager = AppWidgetManager.getInstance(context) - val appWidgetIds = - appWidgetManager.getAppWidgetIds(ComponentName(context, DDWidgetProvider::class.java)) + val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, DDWidgetProvider::class.java)) appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.listView) return@setOnPreferenceChangeListener true 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..983b49a4 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/Theme.kt @@ -0,0 +1,46 @@ +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 +) + +@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/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/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..84c28d1a --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditFragment.kt @@ -0,0 +1,171 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.navigation.findNavController +import dagger.hilt.android.AndroidEntryPoint +import de.psdev.devdrawer.BaseFragment +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import javax.inject.Inject + +@AndroidEntryPoint +class WidgetEditFragment: BaseFragment() { + + // Dependencies + @Inject + lateinit var devDrawerDatabase: DevDrawerDatabase + + @Inject + lateinit var viewModelViewModelFactory: EditWidgetFragmentViewModel.ViewModelFactory + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + ComposeView(requireContext()).apply { + setContent { + DevDrawerTheme { + WidgetEditor( + onEditWidgetProfile = { widgetProfile -> + findNavController().navigate(WidgetEditFragmentDirections.createProfileAction(widgetProfile.id)) + } + ) + } + } + } + +// 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 +// +// +// +// +// +// +// +// +// +// +// +// +// +// } +// } +// } +// 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/ui/editor/WidgetEditor.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditor.kt new file mode 100644 index 00000000..969e245b --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditor.kt @@ -0,0 +1,221 @@ +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.profiles.WidgetEditorViewModel +import de.psdev.devdrawer.profiles.WidgetEditorViewState +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import de.psdev.devdrawer.utils.rememberFlowWithLifecycle +import java.util.* + +@Composable +fun WidgetEditor(onEditWidgetProfile: (WidgetProfile) -> Unit = {}) { + WidgetEditor( + viewModel = hiltViewModel(), + onEditWidgetProfile = onEditWidgetProfile + ) +} + +@Composable +fun WidgetEditor( + viewModel: WidgetEditorViewModel, + onEditWidgetProfile: (WidgetProfile) -> 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 + } + ) + } + } +} + +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/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..ad11a880 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListFragment.kt @@ -0,0 +1,73 @@ +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 { + WidgetListScreenWithModel( + 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..fbdb3aac --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt @@ -0,0 +1,118 @@ +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.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.lifecycle.viewmodel.compose.viewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import java.util.* + +@Composable +fun WidgetListScreenWithModel( + widgetListScreenViewModel: WidgetListScreenViewModel = viewModel(), + onWidgetClick: (Widget) -> Unit = {}, + onRequestPinWidgetClick: () -> Unit = {} +) { + val widgets = widgetListScreenViewModel.widgets.collectAsState(initial = emptyList()).value + WidgetListScreen( + widgets = widgets, + onWidgetClick = onWidgetClick, + onRequestPinWidgetClick = onRequestPinWidgetClick + ) +} + +@Composable +fun WidgetListScreen( + widgets: List, + onWidgetClick: (Widget) -> Unit = {}, + onRequestPinWidgetClick: () -> Unit = {} +) { + // TODO Add loading state + 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") + } + } + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_WidgetListScreen() { + DevDrawerTheme { + WidgetListScreen(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(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..57179c24 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"> @@ -50,7 +48,7 @@ Cannot delete profile, still being used by widgets No Yes + ComposeActivity + + ID: %1$d + + + Create new profile + + + ID: %1$s 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 @@ + + + +