diff --git a/app/src/androidTest/java/org/tasks/ui/editviewmodel/TaskListViewModelTest.kt b/app/src/androidTest/java/org/tasks/ui/editviewmodel/TaskListViewModelTest.kt index 3939eee8d..768b8b551 100644 --- a/app/src/androidTest/java/org/tasks/ui/editviewmodel/TaskListViewModelTest.kt +++ b/app/src/androidTest/java/org/tasks/ui/editviewmodel/TaskListViewModelTest.kt @@ -38,7 +38,7 @@ class TaskListViewModelTest : InjectingTestCase() { override fun setUp() { super.setUp() viewModel = TaskListViewModel( - context = context, + applicationContext = context, preferences = preferences, taskDao = taskDao, deletionDao = deletionDao, diff --git a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt index fe838f053..c2b0fd7ac 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt +++ b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt @@ -27,8 +27,9 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.runtime.getValue +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.ui.platform.LocalContext import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ShareCompat @@ -49,7 +50,6 @@ import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import com.google.android.material.appbar.AppBarLayout @@ -91,7 +91,6 @@ import org.tasks.compose.FilterSelectionActivity.Companion.registerForListPicker import org.tasks.compose.NotificationsDisabledBanner import org.tasks.compose.QuietHoursBanner import org.tasks.compose.SubscriptionNagBanner -import org.tasks.compose.rememberReminderPermissionState import org.tasks.data.TaskContainer import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.TagDataDao @@ -142,6 +141,7 @@ import org.tasks.themes.TasksTheme import org.tasks.themes.Theme import org.tasks.themes.ThemeColor import org.tasks.time.DateTimeUtils2.currentTimeMillis +import org.tasks.ui.Banner import org.tasks.ui.TaskEditEvent import org.tasks.ui.TaskEditEventBus import org.tasks.ui.TaskListEvent @@ -269,7 +269,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL .launchIn(viewLifecycleOwner.lifecycleScope) } - @OptIn(ExperimentalAnimationApi::class, ExperimentalPermissionsApi::class) + @OptIn(ExperimentalPermissionsApi::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { requireActivity().onBackPressedDispatcher.addCallback(owner = viewLifecycleOwner) { @@ -362,65 +362,84 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL val context = LocalContext.current val state = listViewModel.state.collectAsStateWithLifecycle().value TasksTheme(theme = theme.themeBase.index) { - val hasRemindersPermission by rememberReminderPermissionState() val notificationPermissions = if (AndroidUtilities.atLeastTiramisu()) { rememberPermissionState( Manifest.permission.POST_NOTIFICATIONS, onPermissionResult = { success -> if (success) { NotificationSchedulerIntentService.enqueueWork(context) - listViewModel.dismissNotificationBanner(fix = true) + listViewModel.dismissBanner(tookAction = true) } } ) } else { null } - val showNotificationBanner = state.warnNotificationsDisabled && notificationPermissions?.status is PermissionStatus.Denied - val showAlarmsBanner = !showNotificationBanner && state.warnNotificationsDisabled && !hasRemindersPermission - val showSubscriptionNag = !showNotificationBanner && !showAlarmsBanner && state.begForSubscription - val showQuietHoursWarning = !showNotificationBanner && !showAlarmsBanner && !showSubscriptionNag && state.warnQuietHoursEnabled - NotificationsDisabledBanner( - visible = showNotificationBanner, - settings = { - if (notificationPermissions?.status?.shouldShowRationale == true) { - context.openAppNotificationSettings() - } else { - notificationPermissions?.launchPermissionRequest() - } - }, - dismiss = { listViewModel.dismissNotificationBanner() }, - ) - AlarmsDisabledBanner( - visible = showAlarmsBanner, - settings = { context.openReminderSettings() }, - dismiss = { listViewModel.dismissNotificationBanner() }, - ) - SubscriptionNagBanner( - visible = showSubscriptionNag, - subscribe = { - listViewModel.dismissPurchaseBanner(clickedPurchase = true) - if (TasksApplication.IS_GOOGLE_PLAY) { - context.startActivity(Intent(context, PurchaseActivity::class.java)) - } else { - preferences.lastSubscribeRequest = currentTimeMillis() - context.openUri(R.string.url_donate) - } - }, - dismiss = { - listViewModel.dismissPurchaseBanner(clickedPurchase = false) - }, - ) - QuietHoursBanner( - visible = showQuietHoursWarning, - showSettings = { - listViewModel.dismissQuietHoursBanner() - context.startActivity(Intent(context, MainPreferences::class.java)) - }, - dismiss = { - listViewModel.dismissQuietHoursBanner() + + AnimatedVisibility( + visible = state.banner != null, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + when (state.banner) { + is Banner.NotificationsDisabled -> + NotificationsDisabledBanner( + settings = { + if (notificationPermissions?.status?.shouldShowRationale == true) { + context.openAppNotificationSettings() + } else { + notificationPermissions?.launchPermissionRequest() + } + }, + dismiss = { listViewModel.dismissBanner() }, + ) + + Banner.AlarmsDisabled -> + AlarmsDisabledBanner( + settings = { context.openReminderSettings() }, + dismiss = { listViewModel.dismissBanner() }, + ) + + Banner.BegForMoney -> + SubscriptionNagBanner( + subscribe = { + listViewModel.dismissBanner(tookAction = true) + if (TasksApplication.IS_GOOGLE_PLAY) { + context.startActivity( + Intent( + context, + PurchaseActivity::class.java + ) + ) + } else { + preferences.lastSubscribeRequest = currentTimeMillis() + context.openUri(R.string.url_donate) + } + }, + dismiss = { + listViewModel.dismissBanner() + }, + ) + + Banner.QuietHoursEnabled -> + QuietHoursBanner( + showSettings = { + listViewModel.dismissBanner() + context.startActivity( + Intent( + context, + MainPreferences::class.java + ) + ) + }, + dismiss = { + listViewModel.dismissBanner() + } + ) + + null -> {} } - ) + } } } return binding.root diff --git a/app/src/main/java/org/tasks/compose/Banner.kt b/app/src/main/java/org/tasks/compose/Banner.kt index db241e840..9dcf92827 100644 --- a/app/src/main/java/org/tasks/compose/Banner.kt +++ b/app/src/main/java/org/tasks/compose/Banner.kt @@ -1,24 +1,21 @@ package org.tasks.compose import android.content.res.Configuration -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import org.tasks.R import org.tasks.TasksApplication import org.tasks.compose.components.AnimatedBanner +import org.tasks.compose.components.Banner import org.tasks.themes.TasksTheme -@ExperimentalAnimationApi @Composable fun NotificationsDisabledBanner( - visible: Boolean, settings: () -> Unit, dismiss: () -> Unit, ) { - AnimatedBanner( - visible = visible, + Banner( title = stringResource(id = R.string.enable_reminders), body = stringResource(id = R.string.enable_reminders_description), dismissText = stringResource(id = R.string.dismiss), @@ -28,15 +25,12 @@ fun NotificationsDisabledBanner( ) } -@OptIn(ExperimentalAnimationApi::class) @Composable fun AlarmsDisabledBanner( - visible: Boolean, settings: () -> Unit, dismiss: () -> Unit, ) { - AnimatedBanner( - visible = visible, + Banner( title = stringResource(id = R.string.enable_alarms), body = stringResource(id = R.string.enable_alarms_description), dismissText = stringResource(id = R.string.dismiss), @@ -47,15 +41,12 @@ fun AlarmsDisabledBanner( } -@ExperimentalAnimationApi @Composable fun SubscriptionNagBanner( - visible: Boolean, subscribe: () -> Unit, dismiss: () -> Unit, ) { - AnimatedBanner( - visible = visible, + Banner( title = stringResource(id = R.string.enjoying_tasks), body = stringResource(id = if (TasksApplication.IS_GENERIC) { R.string.donate_nag @@ -73,15 +64,12 @@ fun SubscriptionNagBanner( ) } -@OptIn(ExperimentalAnimationApi::class) @Composable fun QuietHoursBanner( - visible: Boolean, showSettings: () -> Unit, dismiss: () -> Unit, ) { - AnimatedBanner( - visible = visible, + Banner( title = stringResource(R.string.quiet_hours_in_effect), body = stringResource(R.string.quiet_hours_summary), dismissText = stringResource(id = R.string.dismiss), @@ -91,7 +79,6 @@ fun QuietHoursBanner( ) } -@ExperimentalAnimationApi @Composable fun BeastModeBanner( visible: Boolean, @@ -109,15 +96,13 @@ fun BeastModeBanner( ) } -@ExperimentalAnimationApi @Preview(showBackground = true) @Preview(showBackground = true, backgroundColor = 0x202124, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun NotificationsDisabledPreview() = TasksTheme { - NotificationsDisabledBanner(visible = true, settings = {}, dismiss = {}) + NotificationsDisabledBanner(settings = {}, dismiss = {}) } -@ExperimentalAnimationApi @Preview(showBackground = true) @Preview(showBackground = true, backgroundColor = 0x202124, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -125,18 +110,16 @@ private fun BeastModePreview() = TasksTheme { BeastModeBanner(visible = true, showSettings = {}, dismiss = {}) } -@ExperimentalAnimationApi @Preview(showBackground = true) @Preview(showBackground = true, backgroundColor = 0x202124, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SubscriptionNagPreview() = TasksTheme { - SubscriptionNagBanner(visible = true, subscribe = {}, dismiss = {}) + SubscriptionNagBanner(subscribe = {}, dismiss = {}) } -@ExperimentalAnimationApi @Preview(showBackground = true) @Preview(showBackground = true, backgroundColor = 0x202124, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun QuietHoursPreview() = TasksTheme { - QuietHoursBanner(visible = true, showSettings = {}, dismiss = {}) + QuietHoursBanner(showSettings = {}, dismiss = {}) } diff --git a/app/src/main/java/org/tasks/preferences/PermissionChecker.java b/app/src/main/java/org/tasks/preferences/PermissionChecker.java index 32b43bd9b..4afdbb8dc 100644 --- a/app/src/main/java/org/tasks/preferences/PermissionChecker.java +++ b/app/src/main/java/org/tasks/preferences/PermissionChecker.java @@ -42,9 +42,13 @@ public class PermissionChecker { return checkPermissions(backgroundPermissions().toArray(new String[0])); } + public boolean hasNotificationPermission() { + return !atLeastTiramisu() || checkPermissions(permission.POST_NOTIFICATIONS); + } + public boolean canNotify() { return org.tasks.extensions.Context.INSTANCE.canScheduleExactAlarms(context) - && (!atLeastTiramisu() || checkPermissions(permission.POST_NOTIFICATIONS)); + && hasNotificationPermission(); } private boolean checkPermissions(String... permissions) { diff --git a/app/src/main/java/org/tasks/preferences/Preferences.kt b/app/src/main/java/org/tasks/preferences/Preferences.kt index a89518822..5d68579a8 100644 --- a/app/src/main/java/org/tasks/preferences/Preferences.kt +++ b/app/src/main/java/org/tasks/preferences/Preferences.kt @@ -562,6 +562,10 @@ class Preferences @JvmOverloads constructor( get() = getBoolean(R.string.p_warn_notifications_disabled, true) set(value) = setBoolean(R.string.p_warn_notifications_disabled, value) + var warnAlarmsDisabled: Boolean + get() = getBoolean(R.string.p_warn_alarms_disabled, true) + set(value) = setBoolean(R.string.p_warn_alarms_disabled, value) + var warnQuietHoursDisabled: Boolean get() = getBoolean(R.string.p_warn_quiet_hours_enabled, true) set(value) = setBoolean(R.string.p_warn_quiet_hours_enabled, value) diff --git a/app/src/main/java/org/tasks/ui/TaskListViewModel.kt b/app/src/main/java/org/tasks/ui/TaskListViewModel.kt index 730595e29..fa00280e3 100644 --- a/app/src/main/java/org/tasks/ui/TaskListViewModel.kt +++ b/app/src/main/java/org/tasks/ui/TaskListViewModel.kt @@ -1,6 +1,5 @@ package org.tasks.ui -import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -32,12 +31,14 @@ import org.tasks.data.dao.TaskDao import org.tasks.data.entity.Task import org.tasks.data.fetchTasks import org.tasks.db.QueryUtils +import org.tasks.extensions.Context.canScheduleExactAlarms import org.tasks.filters.AstridOrderingFilter import org.tasks.filters.EmptyFilter import org.tasks.filters.Filter import org.tasks.filters.FilterImpl import org.tasks.filters.MyTasksFilter import org.tasks.filters.SearchFilter +import org.tasks.preferences.PermissionChecker import org.tasks.preferences.Preferences import org.tasks.preferences.QueryPreferences import org.tasks.tasklist.SectionedDataSource @@ -45,10 +46,16 @@ import org.tasks.tasklist.TasksResults import org.tasks.time.DateTimeUtils2.currentTimeMillis import javax.inject.Inject +sealed interface Banner { + data object NotificationsDisabled : Banner + data object AlarmsDisabled : Banner + data object QuietHoursEnabled : Banner + data object BegForMoney : Banner +} + @HiltViewModel -@SuppressLint("StaticFieldLeak") class TaskListViewModel @Inject constructor( - @ApplicationContext context: Context, + @ApplicationContext private val applicationContext: Context, private val preferences: Preferences, private val taskDao: TaskDao, private val taskDeleter: TaskDeleter, @@ -56,19 +63,17 @@ class TaskListViewModel @Inject constructor( private val localBroadcastManager: LocalBroadcastManager, private val inventory: Inventory, private val firebase: Firebase, + private val permissionChecker: PermissionChecker, ) : ViewModel() { - data class State( val filter: Filter = EmptyFilter(), val now: Long = currentTimeMillis(), val searchQuery: String? = null, val tasks: TasksResults = TasksResults.Loading, - val begForSubscription: Boolean = false, - val warnNotificationsDisabled: Boolean = false, val syncOngoing: Boolean = false, - val warnQuietHoursEnabled: Boolean = false, val collapsed: Set = setOf(SectionedDataSource.HEADER_COMPLETED), + val banner: Banner? = null ) private val _state = MutableStateFlow(State()) @@ -99,23 +104,6 @@ class TaskListViewModel @Inject constructor( } } - fun dismissNotificationBanner( - fix: Boolean = false, - ) { - _state.update { - it.copy(warnNotificationsDisabled = false) - } - preferences.warnNotificationsDisabled = fix - } - - fun dismissPurchaseBanner(clickedPurchase: Boolean) { - _state.update { - it.copy(begForSubscription = false) - } - preferences.lastSubscribeRequest = currentTimeMillis() - firebase.logEvent(R.string.event_banner_sub, R.string.param_click to clickedPurchase) - } - suspend fun getTasksToClear(): List { val filter = _state.value.filter val deleteFilter = FilterImpl( @@ -150,7 +138,7 @@ class TaskListViewModel @Inject constructor( val filter = when { it.searchQuery == null -> it.filter it.searchQuery.isBlank() -> MyTasksFilter.create() - else -> context.createSearchQuery(it.searchQuery) + else -> applicationContext.createSearchQuery(it.searchQuery) } taskDao.fetchTasks { getQuery(preferences, filter) } } @@ -200,26 +188,36 @@ class TaskListViewModel @Inject constructor( fun updateBannerState() { viewModelScope.launch(Dispatchers.IO) { - _state.update { - it.copy( - warnNotificationsDisabled = preferences.warnNotificationsDisabled, - warnQuietHoursEnabled = preferences.isCurrentlyQuietHours && preferences.warnQuietHoursDisabled, - ) + val banner = when { + preferences.warnNotificationsDisabled && !permissionChecker.hasNotificationPermission() -> + Banner.NotificationsDisabled + preferences.warnAlarmsDisabled && !applicationContext.canScheduleExactAlarms() -> + Banner.AlarmsDisabled + (IS_GENERIC || !inventory.hasPro) && !firebase.subscribeCooldown -> + Banner.BegForMoney + preferences.isCurrentlyQuietHours && preferences.warnQuietHoursDisabled -> + Banner.QuietHoursEnabled + else -> null } - - if ((IS_GENERIC || !inventory.hasPro) && !firebase.subscribeCooldown) { - _state.update { - it.copy(begForSubscription = true) - } + _state.update { + it.copy(banner = banner) } } } - fun dismissQuietHoursBanner() = viewModelScope.launch(Dispatchers.IO) { - preferences.warnQuietHoursDisabled = false - _state.update { - it.copy(warnQuietHoursEnabled = false) + fun dismissBanner(tookAction: Boolean = false) { + when (state.value.banner) { + Banner.NotificationsDisabled ->preferences.warnNotificationsDisabled = tookAction + Banner.AlarmsDisabled -> preferences.warnAlarmsDisabled = false + Banner.QuietHoursEnabled -> preferences.warnQuietHoursDisabled = false + Banner.BegForMoney -> { + preferences.lastSubscribeRequest = currentTimeMillis() + firebase.logEvent(R.string.event_banner_sub, R.string.param_click to tookAction) + } + else -> {} } + + updateBannerState() } companion object { diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index cc81fd808..36263a30e 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -407,6 +407,7 @@ install_date default_location warn_notifications_disabled + warn_alarms_disabled warn_quiet_hours_enabled showed_purchase_dialog diff --git a/kmp/src/commonMain/kotlin/org/tasks/compose/components/AnimatedBanner.kt b/kmp/src/commonMain/kotlin/org/tasks/compose/components/AnimatedBanner.kt index f8383f14a..3420db9cd 100644 --- a/kmp/src/commonMain/kotlin/org/tasks/compose/components/AnimatedBanner.kt +++ b/kmp/src/commonMain/kotlin/org/tasks/compose/components/AnimatedBanner.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalAnimationApi::class) + package org.tasks.compose.components import androidx.compose.animation.AnimatedVisibility @@ -19,7 +21,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -@ExperimentalAnimationApi @Composable fun AnimatedBanner( visible: Boolean, @@ -30,8 +31,32 @@ fun AnimatedBanner( action: String, onAction: () -> Unit, ) { - AnimatedBanner( + AnimatedVisibility( visible = visible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Banner( + title = title, + body = body, + dismissText = dismissText, + onDismiss = onDismiss, + action = action, + onAction = onAction, + ) + } +} + +@Composable +fun Banner( + title: String, + body: String, + dismissText: String? = null, + onDismiss: () -> Unit, + action: String, + onAction: () -> Unit, +) { + Banner( content = { Text( text = title, @@ -58,31 +83,24 @@ fun AnimatedBanner( @ExperimentalAnimationApi @Composable -private fun AnimatedBanner( - visible: Boolean, +private fun Banner( content: @Composable () -> Unit, buttons: @Composable () -> Unit, ) { - AnimatedVisibility( - visible = visible, - enter = expandVertically(), - exit = shrinkVertically(), + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) ) { - Card( + Spacer(modifier = Modifier.height(16.dp)) + content() + Row( modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + .padding(vertical = 8.dp, horizontal = 16.dp) + .align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) - content() - Row( - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp) - .align(Alignment.End), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - buttons() - } + buttons() } } }