diff --git a/app/build.gradle b/app/build.gradle index 27714eb..333913a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,6 +48,7 @@ android { buildConfigField 'int', 'minimumRecommendServerVersion', '5' } buildFeatures { + compose true viewBinding true } @@ -151,6 +152,10 @@ android { kotlinOptions { jvmTarget = "1.8" } + + composeOptions { + kotlinCompilerExtensionVersion = "1.4.0-alpha02" + } } wire { @@ -170,6 +175,9 @@ dependencies { implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation "com.google.android.material:material:1.7.0" + implementation 'androidx.compose.material:material:1.3.1' + implementation 'androidx.activity:activity-compose:1.6.1' + implementation 'androidx.compose.material:material-icons-extended:1.3.1' implementation 'androidx.fragment:fragment-ktx:1.5.5' implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" diff --git a/app/src/main/java/io/timelimit/android/data/dao/ChildTaskDao.kt b/app/src/main/java/io/timelimit/android/data/dao/ChildTaskDao.kt index 180a687..a4a7614 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/ChildTaskDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/ChildTaskDao.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,6 +21,7 @@ import androidx.room.* import io.timelimit.android.data.model.ChildTask import io.timelimit.android.data.model.derived.ChildTaskWithCategoryTitle import io.timelimit.android.data.model.derived.FullChildTask +import kotlinx.coroutines.flow.Flow @Dao interface ChildTaskDao { @@ -45,9 +46,12 @@ interface ChildTaskDao { @Query("SELECT child_task.* FROM child_task JOIN category ON (child_task.category_id = category.id) WHERE category.child_id = :userId") fun getTasksByUserIdSync(userId: String): List - @Query("SELECT child_task.*, category.title as category_title, user.name as child_name, user.timezone AS child_timezone FROM child_task JOIN category ON (child_task.category_id = category.id) JOIN user ON (category.child_id = user.id) WHERE child_task.pending_request = 1") + @Query("SELECT child_task.*, category.title as category_title, user.id AS child_id, user.name as child_name, user.timezone AS child_timezone FROM child_task JOIN category ON (child_task.category_id = category.id) JOIN user ON (category.child_id = user.id) WHERE child_task.pending_request = 1") fun getPendingTasks(): LiveData> + @Query("SELECT child_task.*, category.title as category_title, user.id AS child_id, user.name as child_name, user.timezone AS child_timezone FROM child_task JOIN category ON (child_task.category_id = category.id) JOIN user ON (category.child_id = user.id) WHERE child_task.pending_request = 1") + fun getPendingTasksFlow(): Flow> + @Query("SELECT * FROM child_task WHERE category_id = :categoryId") fun getTasksByCategoryId(categoryId: String): LiveData> diff --git a/app/src/main/java/io/timelimit/android/data/dao/ConfigDao.kt b/app/src/main/java/io/timelimit/android/data/dao/ConfigDao.kt index 9443b77..e648037 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/ConfigDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/ConfigDao.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -32,6 +32,8 @@ import io.timelimit.android.livedata.ignoreUnchanged import io.timelimit.android.livedata.map import io.timelimit.android.sync.network.ServerDhKey import io.timelimit.android.update.UpdateStatus +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import java.io.StringWriter @Dao @@ -59,6 +61,13 @@ abstract class ConfigDao { @Query("SELECT * FROM config WHERE id = :key") protected abstract suspend fun getRowCoroutine(key: ConfigurationItemType): ConfigurationItem? + @Query("SELECT * FROM config WHERE id = :key") + protected abstract fun getRowFlow(key: ConfigurationItemType): Flow + + private fun getValueOfKeyFlow(key: ConfigurationItemType): Flow { + return getRowFlow(key).map { it?.value } + } + private suspend fun getValueOfKeyCoroutine(key: ConfigurationItemType): String? { return getRowCoroutine(key)?.value } @@ -81,6 +90,10 @@ abstract class ConfigDao { return getValueOfKeyAsync(ConfigurationItemType.OwnDeviceId) } + fun getOwnDeviceIdFlow(): Flow { + return getValueOfKeyFlow(ConfigurationItemType.OwnDeviceId) + } + fun getOwnDeviceIdSync(): String? { return getValueOfKeySync(ConfigurationItemType.OwnDeviceId) } @@ -213,6 +226,7 @@ abstract class ConfigDao { fun setServerMessage(message: String?) = updateValueSync(ConfigurationItemType.ServerMessage, message ?: "") fun getServerMessage() = getValueOfKeyAsync(ConfigurationItemType.ServerMessage).map { if (it.isNullOrBlank()) null else it } + fun getServerMessageFlow() = getValueOfKeyFlow(ConfigurationItemType.ServerMessage).map { if (it.isNullOrBlank()) null else it } fun getCustomServerUrlSync() = getValueOfKeySync(ConfigurationItemType.CustomServerUrl) ?: "" fun getCustomServerUrlAsync() = getValueOfKeyAsync(ConfigurationItemType.CustomServerUrl).map { it ?: "" } diff --git a/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt b/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt index 1dda4a9..c08be99 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,13 +21,15 @@ import io.timelimit.android.data.model.* import io.timelimit.android.integration.platform.NewPermissionStatusConverter import io.timelimit.android.integration.platform.ProtectionLevelConverter import io.timelimit.android.integration.platform.RuntimePermissionStatusConverter +import kotlinx.coroutines.flow.Flow @Dao @TypeConverters( NetworkTimeAdapter::class, ProtectionLevelConverter::class, RuntimePermissionStatusConverter::class, - NewPermissionStatusConverter::class + NewPermissionStatusConverter::class, + UserTypeConverter::class ) abstract class DeviceDao { @Query("SELECT * FROM device WHERE id = :deviceId") @@ -45,6 +47,9 @@ abstract class DeviceDao { @Query("SELECT * FROM device") abstract fun getAllDevicesSync(): List + @Query("SELECT device.*, user.name AS current_user_name, user.type AS current_user_type FROM device LEFT JOIN user ON (user.id = device.current_user_id)") + abstract fun getAllDevicesWithUserInfoFlow(): Flow> + @Insert abstract fun addDeviceSync(device: Device) @@ -116,4 +121,13 @@ data class DeviceDetailDataBase( val appBaseVersion: String?, @ColumnInfo(name = "app_diff_version") val appDiffVersion: String? +) + +data class DeviceWithUserInfo ( + @Embedded + val device: Device, + @ColumnInfo(name = "current_user_name") + val currentUserName: String?, + @ColumnInfo(name = "current_user_type") + val currentUserType: UserType? ) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/UserDao.kt b/app/src/main/java/io/timelimit/android/data/dao/UserDao.kt index a4ecfbc..206934c 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/UserDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/UserDao.kt @@ -21,6 +21,7 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Update import io.timelimit.android.data.model.User +import kotlinx.coroutines.flow.Flow @Dao abstract class UserDao { @@ -45,6 +46,9 @@ abstract class UserDao { @Query("SELECT * FROM user ORDER by type DESC, name ASC") abstract fun getAllUsersLive(): LiveData> + @Query("SELECT * FROM user ORDER by type DESC, name ASC") + abstract fun getAllUsersFlow(): Flow> + @Query("SELECT * FROM user") abstract fun getAllUsersSync(): List diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/FullChildTask.kt b/app/src/main/java/io/timelimit/android/data/model/derived/FullChildTask.kt index 5334048..c5926ad 100644 --- a/app/src/main/java/io/timelimit/android/data/model/derived/FullChildTask.kt +++ b/app/src/main/java/io/timelimit/android/data/model/derived/FullChildTask.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,6 +25,8 @@ data class FullChildTask( val childTask: ChildTask, @ColumnInfo(name = "category_title") val categoryTitle: String, + @ColumnInfo(name = "child_id") + val childId: String, @ColumnInfo(name = "child_name") val childName: String, @ColumnInfo(name = "child_timezone") diff --git a/app/src/main/java/io/timelimit/android/extensions/Flow.kt b/app/src/main/java/io/timelimit/android/extensions/Flow.kt new file mode 100644 index 0000000..8200ec6 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/extensions/Flow.kt @@ -0,0 +1,38 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.extensions + +import io.timelimit.android.util.Option +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +fun Flow.takeWhileNotNull(): Flow = this.transformWhile { value -> + if (value != null) { + emit(value) + + true + } else false +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow.whileTrue(producer: suspend () -> Flow): Flow = + distinctUntilChanged() + .transformLatest { + if (it) emitAll(producer().map { Option.Some(it) }) + else emit(null) + } + .takeWhileNotNull() + .map { it.value } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/extensions/FragmentManager.kt b/app/src/main/java/io/timelimit/android/extensions/FragmentManager.kt new file mode 100644 index 0000000..9ff4f65 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/extensions/FragmentManager.kt @@ -0,0 +1,11 @@ +package io.timelimit.android.extensions + +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.FragmentManager + +private val onContainerAvailable = FragmentManager::class.java.getDeclaredMethod( + "onContainerAvailable", + FragmentContainerView::class.java +).also { it.isAccessible = true } + +fun FragmentManager.onContainerAvailable(view: FragmentContainerView) = onContainerAvailable.invoke(this, view) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/FragmentScreen.kt b/app/src/main/java/io/timelimit/android/ui/FragmentScreen.kt new file mode 100644 index 0000000..d06fd9d --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/FragmentScreen.kt @@ -0,0 +1,89 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui + +import android.util.Log +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.FragmentManager +import io.timelimit.android.BuildConfig +import io.timelimit.android.extensions.onContainerAvailable +import io.timelimit.android.ui.model.Screen + +private const val LOG_TAG = "FragmentScreen" + +@Composable +fun FragmentScreen( + screen: Screen.FragmentScreen, + fragmentManager: FragmentManager, + fragmentIds: MutableSet, + modifier: Modifier = Modifier +) { + AndroidView( + modifier = modifier.fillMaxSize(), + factory = { context -> + FragmentContainerView(context).also { + val containerId = screen.fragment.containerId + val fragment = fragmentManager.findFragmentById(containerId) + + it.id = containerId + + if (fragment == null) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "connect $screen") + } + + fragmentManager.beginTransaction() + .replace( + containerId, + screen.fragment.fragmentClass, + screen.fragment.arguments + ) + .commitAllowingStateLoss() + } else { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "already bound $screen") + } + + fragmentManager.beginTransaction() + .attach(fragment) + .commitAllowingStateLoss() + } + + fragmentManager.onContainerAvailable(it) + fragmentIds.add(containerId) + } + } + ) + + DisposableEffect(true) { + onDispose { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "detach $screen") + } + + fragmentManager.findFragmentById(screen.fragment.containerId)?.also { fragment -> + fragmentManager.beginTransaction() + .detach(fragment) + .commitAllowingStateLoss() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/MainActivity.kt b/app/src/main/java/io/timelimit/android/ui/MainActivity.kt index 23ea8cc..9af9045 100644 --- a/app/src/main/java/io/timelimit/android/ui/MainActivity.kt +++ b/app/src/main/java/io/timelimit/android/ui/MainActivity.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,48 +20,61 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.SystemClock -import android.view.MenuItem +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.DialogFragment +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.Transformations -import androidx.lifecycle.ViewModelProviders -import androidx.navigation.NavController -import androidx.navigation.NavDestination -import androidx.navigation.fragment.NavHostFragment +import androidx.lifecycle.* import io.timelimit.android.Application +import io.timelimit.android.BuildConfig import io.timelimit.android.R import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.model.UserType import io.timelimit.android.extensions.showSafe import io.timelimit.android.integration.platform.android.NotificationChannels import io.timelimit.android.livedata.ignoreUnchanged import io.timelimit.android.livedata.liveDataFromNullableValue import io.timelimit.android.livedata.map -import io.timelimit.android.livedata.switchMap import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.u2f.U2fManager import io.timelimit.android.u2f.protocol.U2FDevice +import io.timelimit.android.ui.animation.Transition import io.timelimit.android.ui.login.AuthTokenLoginProcessor import io.timelimit.android.ui.login.NewLoginFragment import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticatedUser import io.timelimit.android.ui.main.FragmentWithCustomTitle -import io.timelimit.android.ui.manage.parent.ManageParentFragmentArgs +import io.timelimit.android.ui.model.* import io.timelimit.android.ui.payment.ActivityPurchaseModel import io.timelimit.android.ui.util.SyncStatusModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import java.security.SecureRandom -class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.DeviceFoundListener { +class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.DeviceFoundListener, MainModelActivity { companion object { + private const val LOG_TAG = "MainActivity" private const val AUTH_DIALOG_TAG = "adt" const val ACTION_USER_OPTIONS = "OPEN_USER_OPTIONS" const val EXTRA_USER_ID = "userId" private const val EXTRA_AUTH_HANDOVER = "authHandover" + private const val MAIN_MODEL_STATE = "mainModelState" + private const val FRAGMENT_IDS_STATE = "fragmentIds" private var authHandover: Triple? = null @@ -95,7 +108,8 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.De } } - private val currentNavigatorFragment = MutableLiveData() + private val mainModel by viewModels() + private var fragmentIds = mutableSetOf() private val syncModel: SyncStatusModel by lazy { ViewModelProviders.of(this).get(SyncStatusModel::class.java) } @@ -103,114 +117,162 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.De override var ignoreStop: Boolean = false override val showPasswordRecovery: Boolean = true + @OptIn(ExperimentalAnimationApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + + supportActionBar!!.hide() U2fManager.setupActivity(this) NotificationChannels.createNotificationChannels(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager, this) - if (savedInstanceState == null) { - NavHostFragment.create(R.navigation.nav_graph).let { navhost -> - supportFragmentManager.beginTransaction() - .replace(R.id.nav_host, navhost) - .setPrimaryNavigationFragment(navhost) - .commitNow() - } + if (savedInstanceState != null) { + mainModel.state.value = savedInstanceState.getSerializable(MAIN_MODEL_STATE) as State + fragmentIds.addAll(savedInstanceState.getIntegerArrayList(FRAGMENT_IDS_STATE) ?: emptyList()) } // init the purchaseModel purchaseModel.getApplication() - // prepare livedata - val customTitle = currentNavigatorFragment.switchMap { - if (it != null && it is FragmentWithCustomTitle) { - it.getCustomTitle() - } else { - liveDataFromNullableValue(null as String?) - } - }.ignoreUnchanged() - - val title = Transformations.map(customTitle) { - if (it == null) { - getString(R.string.app_name) - } else { - it - } - } - - // up button - getNavController().addOnDestinationChangedListener(object: NavController.OnDestinationChangedListener { - override fun onDestinationChanged(controller: NavController, destination: NavDestination, arguments: Bundle?) { - supportActionBar!!.setDisplayHomeAsUpEnabled(controller.previousBackStackEntry != null) - } - }) - // init if not yet done DefaultAppLogic.with(this) - val fragmentContainer = supportFragmentManager.findFragmentById(R.id.nav_host)!! - val fragmentContainerManager = fragmentContainer.childFragmentManager + val fragments = MutableStateFlow(emptyMap()) - fragmentContainerManager.registerFragmentLifecycleCallbacks(object: FragmentManager.FragmentLifecycleCallbacks() { + supportFragmentManager.registerFragmentLifecycleCallbacks(object: FragmentManager.FragmentLifecycleCallbacks() { override fun onFragmentStarted(fm: FragmentManager, f: Fragment) { super.onFragmentStarted(fm, f) - if (!(f is DialogFragment)) { - currentNavigatorFragment.value = f + fragments.update { + it + Pair(f.id, f) } } override fun onFragmentStopped(fm: FragmentManager, f: Fragment) { super.onFragmentStopped(fm, f) - if (currentNavigatorFragment.value === f) { - currentNavigatorFragment.value = null + fragments.update { + it - f.id } + + cleanupFragments() } }, false) - title.observe(this, Observer { setTitle(it) }) - syncModel.statusText.observe(this, Observer { supportActionBar!!.subtitle = it }) - handleParameters(intent) val hasDeviceId = getActivityViewModel().logic.deviceId.map { it != null }.ignoreUnchanged() val hasParentKey = getActivityViewModel().logic.database.config().getParentModeKeyLive().map { it != null }.ignoreUnchanged() hasDeviceId.observe(this) { - val rootDestination = getNavController().backQueue.getOrNull(1)?.destination?.id + val rootDestination = mainModel.state.value.first() if (!it) getActivityViewModel().logOut() if ( - it && rootDestination != R.id.overviewFragment || - !it && rootDestination == R.id.overviewFragment + it && rootDestination !is State.Overview || + !it && rootDestination is State.Overview ) { restartContent() } } hasParentKey.observe(this) { - val rootDestination = getNavController().backQueue.getOrNull(1)?.destination?.id + val rootDestination = mainModel.state.value.first() if ( - it && rootDestination != R.id.parentModeFragment || - !it && rootDestination == R.id.parentModeFragment + it && rootDestination !is State.ParentMode || + !it && rootDestination is State.ParentMode ) { restartContent() } } + + setContent { + Theme { + val screenLive by mainModel.screen.collectAsState(initial = null) + val subtitleLive by syncModel.statusText.asFlow().collectAsState(initial = null) + + val isAuthenticated by getActivityViewModel().authenticatedUser + .map { it?.second?.type == UserType.Parent } + .asFlow().collectAsState(initial = false) + + BackHandler(enabled = screenLive?.state?.previous != null) { + execute(UpdateStateCommand.BackToPreviousScreen) + } + + updateTransition( + targetState = screenLive, + label = "AnimatedContent" + ).AnimatedContent( + modifier = Modifier.background(Color.Black), + contentKey = { screen -> + when (screen) { + is Screen.FragmentScreen -> screen.fragment.containerId + is Screen.OverviewScreen -> "overview" + null -> null + } + }, + transitionSpec = { + val from = initialState + val to = targetState + + if (from == null || to == null) Transition.none + else { + val isOpening = to.state.hasPrevious(from.state) + val isClosing = from.state.hasPrevious(to.state) + + if (isOpening) Transition.openScreen + else if (isClosing) Transition.closeScreen + else Transition.none + } + } + ) { screen -> + val showAuthenticationDialog = + if (screen is ScreenWithAuthenticationFab && !isAuthenticated) ::showAuthenticationScreen + else null + + val fragmentsLive by fragments.collectAsState() + + val customTitle by when (screen) { + is Screen.FragmentScreen -> { + when (val fragment = fragmentsLive[screen.fragment.containerId]) { + is FragmentWithCustomTitle -> fragment.getCustomTitle() + else -> liveDataFromNullableValue(null) + } + } + else -> liveDataFromNullableValue(null) + }.asFlow().collectAsState(initial = null) + + ScreenScaffold( + screen = screen, + title = customTitle ?: stringResource(R.string.app_name), + subtitle = subtitleLive, + executeCommand = ::execute, + content = { paddingValues -> + ScreenMultiplexer( + screen = screen, + executeCommand = ::execute, + fragmentManager = supportFragmentManager, + fragmentIds = fragmentIds, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) + }, + showAuthenticationDialog = showAuthenticationDialog + ) + } + } + } } - override fun onOptionsItemSelected(item: MenuItem) = when { - item.itemId == android.R.id.home -> { - onBackPressed() + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) - true - } - else -> super.onOptionsItemSelected(item) + outState.putSerializable(MAIN_MODEL_STATE, mainModel.state.value) + outState.putIntegerArrayList(FRAGMENT_IDS_STATE, ArrayList(fragmentIds)) } override fun onStart() { @@ -252,17 +314,7 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.De val valid = userId != null && try { IdGenerator.assertIdValid(userId); true } catch (ex: IllegalArgumentException) {false} if (userId != null && valid) { - getNavController().popBackStack(R.id.overviewFragment, true) - getNavController().handleDeepLink( - getNavController().createDeepLink() - .setDestination(R.id.manageParentFragment) - .setArguments(ManageParentFragmentArgs(userId).toBundle()) - .createTaskStackBuilder() - .intents - .first() - ) - - return true + execute(UpdateStateCommand.RecoverPassword(userId)) } } @@ -277,47 +329,53 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.De } if (handleParameters(intent)) return - - // at these screens, some users restart the App - // if they want to continue after opening the mail - // because they don't understand how to use the list of running Apps ... - // Due to that, on the relevant screens, the App does not - // go back to the start when opening it again - val isImportantScreen = when (getNavController().currentDestination?.id) { - R.id.setupParentModeFragment -> true - R.id.restoreParentPasswordFragment -> true - R.id.linkParentMailFragment -> true - else -> false - } - - if (!isImportantScreen) restartContent() } private fun restartContent() { - while (getNavController().popBackStack()) {/* do nothing */} - - getNavController().clearBackStack(R.id.launchFragment) - getNavController().navigate(R.id.launchFragment) + mainModel.execute(UpdateStateCommand.Reset) } override fun getActivityViewModel(): ActivityViewModel { return ViewModelProviders.of(this).get(ActivityViewModel::class.java) } - private fun getNavHostFragment(): NavHostFragment { - return supportFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment - } - - private fun getNavController(): NavController { - return getNavHostFragment().navController - } - override fun showAuthenticationScreen() { if (supportFragmentManager.findFragmentByTag(AUTH_DIALOG_TAG) == null) { NewLoginFragment().showSafe(supportFragmentManager, AUTH_DIALOG_TAG) } } + private fun cleanupFragments() { + fragmentIds + .filter { fragmentId -> + var v = mainModel.state.value as State? + + while (v != null) { + if (v is FragmentState && v.containerId == fragmentId) return@filter false + + v = v.previous + } + + true + } + .map { supportFragmentManager.findFragmentById(it) } + .filterNotNull() + .filter { it.isDetached } + .forEach { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "remove fragment $it") + } + + val id = it.id + + supportFragmentManager.beginTransaction() + .remove(it) + .commitAllowingStateLoss() + + fragmentIds.remove(id) + } + } + override fun onResume() { super.onResume() @@ -331,4 +389,6 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.De } override fun onDeviceFound(device: U2FDevice) = AuthTokenLoginProcessor.process(device, getActivityViewModel()) + + override fun execute(command: UpdateStateCommand) = mainModel.execute(command) } diff --git a/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt b/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt new file mode 100644 index 0000000..aa5fd88 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt @@ -0,0 +1,38 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.fragment.app.FragmentManager +import io.timelimit.android.ui.model.Screen +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.overview.overview.OverviewScreen + +@Composable +fun ScreenMultiplexer( + screen: Screen?, + executeCommand: (UpdateStateCommand) -> Unit, + fragmentManager: FragmentManager, + fragmentIds: MutableSet, + modifier: Modifier = Modifier +) { + when (screen) { + null -> {/* nothing to do */ } + is Screen.OverviewScreen -> OverviewScreen(screen.content, executeCommand, modifier = modifier) + is Screen.FragmentScreen -> FragmentScreen(screen, fragmentManager, fragmentIds, modifier = modifier) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/ScreenScaffold.kt b/app/src/main/java/io/timelimit/android/ui/ScreenScaffold.kt new file mode 100644 index 0000000..661b85b --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/ScreenScaffold.kt @@ -0,0 +1,111 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import io.timelimit.android.R +import io.timelimit.android.ui.model.Screen +import io.timelimit.android.ui.model.UpdateStateCommand + +@Composable +fun ScreenScaffold( + screen: Screen?, + title: String, + subtitle: String?, + content: @Composable (PaddingValues) -> Unit, + executeCommand: (UpdateStateCommand) -> Unit, + showAuthenticationDialog: (() -> Unit)? +) { + var expandDropdown by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.subtitle1, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + navigationIcon = if (screen?.state?.previous != null) ({ + IconButton(onClick = { executeCommand(UpdateStateCommand.BackToPreviousScreen) }) { + Icon(Icons.Default.ArrowBack, stringResource(R.string.generic_back)) + } + }) else null, + actions = { + for (icon in screen?.toolbarIcons ?: emptyList()) { + IconButton( + onClick = { + executeCommand(icon.action) + } + ) { + Icon(icon.icon, stringResource(icon.labelResource)) + } + } + + if (screen?.toolbarOptions?.isEmpty() == false) { + IconButton(onClick = { expandDropdown = true }) { + Icon(Icons.Default.MoreVert, stringResource(R.string.generic_menu)) + } + + DropdownMenu( + expanded = expandDropdown, + onDismissRequest = { expandDropdown = false } + ) { + for (option in screen.toolbarOptions) { + DropdownMenuItem(onClick = { + executeCommand(option.action) + expandDropdown = false + }) { + Text(stringResource(option.labelResource)) + } + } + } + } + } + ) + }, + floatingActionButton = { + if (showAuthenticationDialog != null) { + FloatingActionButton(onClick = showAuthenticationDialog) { + Icon(Icons.Default.LockOpen, stringResource(R.string.authentication_action)) + } + } + }, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/Theme.kt b/app/src/main/java/io/timelimit/android/ui/Theme.kt new file mode 100644 index 0000000..85b2c4d --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/Theme.kt @@ -0,0 +1,39 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import io.timelimit.android.R + +@Composable +fun Theme( + content: @Composable () -> Unit +) { + val resources = LocalContext.current.resources + + MaterialTheme( + content = content, + colors = MaterialTheme.colors.copy( + primary = Color(resources.getColor(R.color.colorPrimary)), + primaryVariant = Color(resources.getColor(R.color.colorPrimaryDark)), + secondary = Color(resources.getColor(R.color.colorAccent)), + onSecondary = Color.White + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/animation/Transition.kt b/app/src/main/java/io/timelimit/android/ui/animation/Transition.kt new file mode 100644 index 0000000..d13fb78 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/animation/Transition.kt @@ -0,0 +1,22 @@ +package io.timelimit.android.ui.animation + +import androidx.compose.animation.* + +@OptIn(ExperimentalAnimationApi::class) +object Transition { + val openScreen = ContentTransform( + targetContentEnter = slideInHorizontally { it }, + initialContentExit = slideOutHorizontally() + fadeOut(targetAlpha = .5f) + ) + + val closeScreen = ContentTransform( + targetContentEnter = slideInHorizontally { -it / 2 } + fadeIn(initialAlpha = .5f), + initialContentExit = slideOutHorizontally { it }, + targetContentZIndex = -1f + ) + + val none = ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt index 698ecb3..70dc646 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,72 +22,50 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.databinding.FragmentDiagnoseMainBinding -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.livedata.liveDataFromNonNullValue import io.timelimit.android.livedata.liveDataFromNullableValue import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class DiagnoseMainFragment : Fragment(), FragmentWithCustomTitle { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val binding = FragmentDiagnoseMainBinding.inflate(inflater, container, false) - val navigation = Navigation.findNavController(container!!) val logic = DefaultAppLogic.with(requireContext()) val activity: ActivityViewModelHolder = activity as ActivityViewModelHolder val auth = activity.getActivityViewModel() binding.diagnoseClockButton.setOnClickListener { - navigation.safeNavigate( - DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseClockFragment(), - R.id.diagnoseMainFragment - ) + requireActivity().execute(UpdateStateCommand.Diagnose.Clock) } binding.diagnoseConnectionButton.setOnClickListener { - navigation.safeNavigate( - DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseConnectionFragment(), - R.id.diagnoseMainFragment - ) + requireActivity().execute(UpdateStateCommand.Diagnose.Connection) } binding.diagnoseSyncButton.setOnClickListener { - navigation.safeNavigate( - DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseSyncFragment(), - R.id.diagnoseMainFragment - ) + requireActivity().execute(UpdateStateCommand.Diagnose.Sync) } binding.diagnoseCryButton.setOnClickListener { - navigation.safeNavigate( - DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseCryptoFragment(), - R.id.diagnoseMainFragment - ) + requireActivity().execute(UpdateStateCommand.Diagnose.Crypto) } binding.diagnoseBatteryButton.setOnClickListener { - navigation.safeNavigate( - DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseBatteryFragment(), - R.id.diagnoseMainFragment - ) + requireActivity().execute(UpdateStateCommand.Diagnose.Battery) } binding.diagnoseFgaButton.setOnClickListener { - navigation.safeNavigate( - DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseForegroundAppFragment(), - R.id.diagnoseMainFragment - ) + requireActivity().execute(UpdateStateCommand.Diagnose.ForegroundApp) } binding.diagnoseExfButton.setOnClickListener { - navigation.safeNavigate( - DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseExperimentalFlagFragment(), - R.id.diagnoseMainFragment - ) + requireActivity().execute(UpdateStateCommand.Diagnose.ExperimentalFlags) } logic.backgroundTaskLogic.lastLoopException.observe(this, Observer { ex -> @@ -108,10 +86,7 @@ class DiagnoseMainFragment : Fragment(), FragmentWithCustomTitle { } binding.diagnoseExitReasonsButton.setOnClickListener { - navigation.safeNavigate( - DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseExitReasonFragment(), - R.id.diagnoseMainFragment - ) + requireActivity().execute(UpdateStateCommand.Diagnose.ExitReasons) } AuthenticationFab.manageAuthenticationFab( diff --git a/app/src/main/java/io/timelimit/android/ui/fragment/CategoryFragmentWrappers.kt b/app/src/main/java/io/timelimit/android/ui/fragment/CategoryFragmentWrappers.kt index 70fb8fd..2569815 100644 --- a/app/src/main/java/io/timelimit/android/ui/fragment/CategoryFragmentWrappers.kt +++ b/app/src/main/java/io/timelimit/android/ui/fragment/CategoryFragmentWrappers.kt @@ -27,6 +27,8 @@ import io.timelimit.android.livedata.switchMap import io.timelimit.android.ui.main.FragmentWithCustomTitle import io.timelimit.android.ui.manage.category.blocked_times.BlockedTimeAreasFragment import io.timelimit.android.ui.manage.category.settings.CategorySettingsFragment +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute abstract class CategoryFragmentWrapper: SingleFragmentWrapper(), FragmentWithCustomTitle { abstract val childId: String @@ -44,7 +46,7 @@ abstract class CategoryFragmentWrapper: SingleFragmentWrapper(), FragmentWithCus super.onViewCreated(view, savedInstanceState) category.observe(viewLifecycleOwner) { - if (it == null) navigation.popBackStack() + if (it == null) requireActivity().execute(UpdateStateCommand.ManageChild.LeaveCategory) } } diff --git a/app/src/main/java/io/timelimit/android/ui/fragment/ChildFragmentWrappers.kt b/app/src/main/java/io/timelimit/android/ui/fragment/ChildFragmentWrappers.kt index cfc52b7..3268ef0 100644 --- a/app/src/main/java/io/timelimit/android/ui/fragment/ChildFragmentWrappers.kt +++ b/app/src/main/java/io/timelimit/android/ui/fragment/ChildFragmentWrappers.kt @@ -26,6 +26,8 @@ import io.timelimit.android.ui.manage.category.usagehistory.UsageHistoryFragment import io.timelimit.android.ui.manage.child.advanced.ManageChildAdvancedFragment import io.timelimit.android.ui.manage.child.apps.ChildAppsFragment import io.timelimit.android.ui.manage.child.tasks.ManageChildTasksFragment +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute abstract class ChildFragmentWrapper: SingleFragmentWrapper() { abstract val childId: String @@ -37,7 +39,7 @@ abstract class ChildFragmentWrapper: SingleFragmentWrapper() { super.onViewCreated(view, savedInstanceState) child.observe(viewLifecycleOwner) { - if (it == null) navigation.popBackStack() + if (it == null) requireActivity().execute(UpdateStateCommand.ManageChild.LeaveChild) } } } diff --git a/app/src/main/java/io/timelimit/android/ui/fragment/SingleFragmentWrapper.kt b/app/src/main/java/io/timelimit/android/ui/fragment/SingleFragmentWrapper.kt index 2cb3f19..581245b 100644 --- a/app/src/main/java/io/timelimit/android/ui/fragment/SingleFragmentWrapper.kt +++ b/app/src/main/java/io/timelimit/android/ui/fragment/SingleFragmentWrapper.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,8 +20,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import androidx.navigation.NavController -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.databinding.SingleFragmentWrapperBinding import io.timelimit.android.livedata.liveDataFromNonNullValue @@ -30,14 +28,9 @@ import io.timelimit.android.ui.main.AuthenticationFab abstract class SingleFragmentWrapper: Fragment() { val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } - private lateinit var navController: NavController protected lateinit var binding: SingleFragmentWrapperBinding - protected val navigation get() = navController - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - navController = Navigation.findNavController(container!!) - binding = SingleFragmentWrapperBinding.inflate(inflater, container, false) AuthenticationFab.manageAuthenticationFab( diff --git a/app/src/main/java/io/timelimit/android/ui/fragment/SingleFragmentWrappers.kt b/app/src/main/java/io/timelimit/android/ui/fragment/SingleFragmentWrappers.kt index 5f0b743..524cf28 100644 --- a/app/src/main/java/io/timelimit/android/ui/fragment/SingleFragmentWrappers.kt +++ b/app/src/main/java/io/timelimit/android/ui/fragment/SingleFragmentWrappers.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,8 +17,8 @@ package io.timelimit.android.ui.fragment import androidx.fragment.app.Fragment -import io.timelimit.android.R -import io.timelimit.android.extensions.safeNavigate +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute import io.timelimit.android.ui.overview.about.AboutFragment import io.timelimit.android.ui.overview.about.AboutFragmentParentHandlers @@ -27,23 +27,14 @@ class AboutFragmentWrapped: SingleFragmentWrapper(), AboutFragmentParentHandlers override fun createChildFragment(): Fragment = AboutFragment() override fun onShowDiagnoseScreen() { - navigation.safeNavigate( - AboutFragmentWrappedDirections.actionAboutFragmentWrappedToDiagnoseMainFragment(), - R.id.aboutFragmentWrapped - ) + requireActivity().execute(UpdateStateCommand.About.Diagnose) } override fun onShowPurchaseScreen() { - navigation.safeNavigate( - AboutFragmentWrappedDirections.actionAboutFragmentWrappedToPurchaseFragment(), - R.id.aboutFragmentWrapped - ) + requireActivity().execute(UpdateStateCommand.About.Purchase) } override fun onShowStayAwesomeScreen() { - navigation.safeNavigate( - AboutFragmentWrappedDirections.actionAboutFragmentWrappedToStayAwesomeFragment(), - R.id.aboutFragmentWrapped - ) + requireActivity().execute(UpdateStateCommand.About.StayAwesome) } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/launch/LaunchFragment.kt b/app/src/main/java/io/timelimit/android/ui/launch/LaunchFragment.kt deleted file mode 100644 index 8718e4c..0000000 --- a/app/src/main/java/io/timelimit/android/ui/launch/LaunchFragment.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.launch - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.navigation.Navigation -import io.timelimit.android.R -import io.timelimit.android.extensions.safeNavigate -import io.timelimit.android.ui.obsolete.ObsoleteDialogFragment -import io.timelimit.android.ui.overview.main.MainFragmentDirections - -class LaunchFragment: Fragment() { - private val model by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - ObsoleteDialogFragment.show(requireActivity(), false) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.circular_progress_indicator, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val navigation = Navigation.findNavController(view) - - model.action.observe(viewLifecycleOwner) { - when (it) { - LaunchModel.Action.Setup -> navigation.safeNavigate(LaunchFragmentDirections.actionLaunchFragmentToSetupTermsFragment(), R.id.launchFragment) - LaunchModel.Action.Overview -> navigation.safeNavigate(LaunchFragmentDirections.actionLaunchFragmentToOverviewFragment(), R.id.launchFragment) - is LaunchModel.Action.Child -> { - navigation.safeNavigate(LaunchFragmentDirections.actionLaunchFragmentToOverviewFragment(), R.id.launchFragment) - navigation.safeNavigate(MainFragmentDirections.actionOverviewFragmentToManageChildFragment(it.id, fromRedirect = true), R.id.overviewFragment) - } - LaunchModel.Action.DeviceSetup -> { - navigation.safeNavigate(LaunchFragmentDirections.actionLaunchFragmentToOverviewFragment(), R.id.launchFragment) - navigation.safeNavigate(MainFragmentDirections.actionOverviewFragmentToSetupDeviceFragment(), R.id.overviewFragment) - } - LaunchModel.Action.ParentMode -> navigation.safeNavigate(LaunchFragmentDirections.actionLaunchFragmentToParentModeFragment(), R.id.launchFragment) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/launch/LaunchModel.kt b/app/src/main/java/io/timelimit/android/ui/launch/LaunchModel.kt deleted file mode 100644 index 9b9c8e4..0000000 --- a/app/src/main/java/io/timelimit/android/ui/launch/LaunchModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.launch - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import io.timelimit.android.async.Threads -import io.timelimit.android.coroutines.executeAndWait -import io.timelimit.android.data.model.UserType -import io.timelimit.android.livedata.castDown -import io.timelimit.android.livedata.waitUntilValueMatches -import io.timelimit.android.logic.DefaultAppLogic -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class LaunchModel(application: Application): AndroidViewModel(application) { - private val actionInternal = MutableLiveData() - private val logic = DefaultAppLogic.with(application) - - val action = actionInternal.castDown() - - init { - viewModelScope.launch { - withContext(Dispatchers.Main) { - logic.isInitialized.waitUntilValueMatches { it == true } - - actionInternal.value = Threads.database.executeAndWait { - val hasDeviceId = logic.database.config().getOwnDeviceIdSync() != null - val hasParentKey = logic.database.config().getParentModeKeySync() != null - - if (hasDeviceId) { - val config = logic.database.derivedDataDao().getUserAndDeviceRelatedDataSync() - - if (config?.userRelatedData?.user?.type == UserType.Child) Action.Child(config.userRelatedData.user.id) - else if (config?.userRelatedData == null) Action.DeviceSetup - else Action.Overview - } - else if (hasParentKey) Action.ParentMode - else Action.Setup - } - } - } - } - - sealed class Action { - object Setup: Action() - object Overview: Action() - data class Child(val id: String): Action() - object DeviceSetup: Action() - object ParentMode: Action() - } -} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/ManageCategoryFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/ManageCategoryFragment.kt index 88ced7a..d57f62b 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/ManageCategoryFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/ManageCategoryFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,13 +15,7 @@ */ package io.timelimit.android.ui.manage.category -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import androidx.fragment.app.Fragment -import io.timelimit.android.R -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.ui.fragment.CategoryFragmentWrapper import io.timelimit.android.ui.main.FragmentWithCustomTitle import io.timelimit.android.ui.manage.category.appsandrules.CombinedAppsAndRulesFragment @@ -35,42 +29,4 @@ class ManageCategoryFragment : CategoryFragmentWrapper(), FragmentWithCustomTitl childId = childId, categoryId = categoryId ) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setHasOptionsMenu(true) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - - inflater.inflate(R.menu.fragment_manage_category_menu, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - R.id.menu_manage_category_blocked_time_areas -> { - navigation.safeNavigate( - ManageCategoryFragmentDirections.actionManageCategoryFragmentToBlockedTimeAreasFragmentWrapper( - childId = params.childId, - categoryId = params.categoryId - ), - R.id.manageCategoryFragment - ) - - true - } - R.id.menu_manage_category_settings -> { - navigation.safeNavigate( - ManageCategoryFragmentDirections.actionManageCategoryFragmentToCategoryAdvancedFragmentWrapper( - childId = params.childId, - categoryId = params.categoryId - ), - R.id.manageCategoryFragment - ) - - true - } - else -> super.onOptionsItemSelected(item) - } } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt index 5f8788f..cf13298 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,14 +16,10 @@ package io.timelimit.android.ui.manage.child import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View import androidx.fragment.app.Fragment import com.google.android.material.snackbar.Snackbar import io.timelimit.android.R -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.livedata.map import io.timelimit.android.ui.fragment.ChildFragmentWrapper import io.timelimit.android.ui.main.FragmentWithCustomTitle @@ -49,55 +45,5 @@ class ManageChildFragment : ChildFragmentWrapper(), FragmentWithCustomTitle { } } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - - inflater.inflate(R.menu.fragment_manage_child_menu, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - R.id.menu_manage_child_apps -> { - navigation.safeNavigate( - ManageChildFragmentDirections.actionManageChildFragmentToChildAppsFragmentWrapper(childId = childId), - R.id.manageChildFragment - ) - - true - } - R.id.menu_manage_child_advanced -> { - navigation.safeNavigate( - ManageChildFragmentDirections.actionManageChildFragmentToChildAdvancedFragmentWrapper(childId = childId), - R.id.manageChildFragment - ) - - true - } - R.id.menu_manage_child_phone -> { - navigation.safeNavigate( - ManageChildFragmentDirections.actionManageChildFragmentToContactsFragment(), - R.id.manageChildFragment - ) - - true - } - R.id.menu_manage_child_usage_history -> { - navigation.safeNavigate( - ManageChildFragmentDirections.actionManageChildFragmentToChildUsageHistoryFragmentWrapper(childId = childId), - R.id.manageChildFragment - ) - - true - } - R.id.menu_manage_child_tasks -> { - navigation.safeNavigate( - ManageChildFragmentDirections.actionManageChildFragmentToManageChildTasksFragment(childId = childId), - R.id.manageChildFragment - ) - - true - } - else -> super.onOptionsItemSelected(item) - } - override fun getCustomTitle() = child.map { "${it?.name} < ${getString(R.string.main_tab_overview)}" as String? } } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt index a218f6f..514f91e 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,16 +22,13 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Observer -import androidx.navigation.Navigation import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import io.timelimit.android.R import io.timelimit.android.async.Threads import io.timelimit.android.data.model.Category import io.timelimit.android.data.model.HintsToShow import io.timelimit.android.databinding.RecyclerFragmentBinding -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.sync.actions.UpdateCategoryDisableLimitsAction @@ -41,10 +38,11 @@ import io.timelimit.android.ui.consent.SyncAppListConsentDialogFragment import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.manage.child.ManageChildFragmentArgs -import io.timelimit.android.ui.manage.child.ManageChildFragmentDirections import io.timelimit.android.ui.manage.child.category.create.CreateCategoryDialogFragment import io.timelimit.android.ui.manage.child.category.specialmode.SetCategorySpecialModeFragment import io.timelimit.android.ui.manage.child.category.specialmode.SpecialModeDialogMode +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class ManageChildCategoriesFragment : Fragment() { companion object { @@ -69,17 +67,13 @@ class ManageChildCategoriesFragment : Fragment() { super.onViewCreated(view, savedInstanceState) val adapter = Adapter() - val navigation = Navigation.findNavController(view) adapter.handlers = object: Handlers { override fun onCategoryClicked(category: Category) { - navigation.safeNavigate( - ManageChildFragmentDirections.actionManageChildFragmentToManageCategoryFragment( - params.childId, - category.id - ), - R.id.manageChildFragment - ) + requireActivity().execute(UpdateStateCommand.ManageChild.Category( + childId = params.childId, + categoryId = category.id + )) } override fun onCreateCategoryClicked() { diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt index 6bb0b1f..2265136 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,13 +25,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.switchMap -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.crypto.Curve25519 import io.timelimit.android.crypto.HexString import io.timelimit.android.data.model.Device import io.timelimit.android.databinding.FragmentManageDeviceBinding -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.livedata.* import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic @@ -42,6 +40,8 @@ import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle import io.timelimit.android.ui.manage.device.manage.feature.ManageDeviceFeaturesFragment import io.timelimit.android.ui.manage.device.manage.permission.ManageDevicePermissionsFragment +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } @@ -52,8 +52,7 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { logic.database.device().getDeviceById(args.deviceId) } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val navigation = Navigation.findNavController(container!!) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val binding = FragmentManageDeviceBinding.inflate(inflater, container, false) val userEntries = logic.database.user().getAllUsersLive() @@ -82,39 +81,19 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { binding.handlers = object: ManageDeviceFragmentHandlers { override fun showUserScreen() { - navigation.safeNavigate( - ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceUserFragment( - deviceId = args.deviceId - ), - R.id.manageDeviceFragment - ) + requireActivity().execute(UpdateStateCommand.ManageDevice.User(args.deviceId)) } override fun showPermissionsScreen() { - navigation.safeNavigate( - ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDevicePermissionsFragment( - deviceId = args.deviceId - ), - R.id.manageDeviceFragment - ) + requireActivity().execute(UpdateStateCommand.ManageDevice.Permissions(args.deviceId)) } override fun showFeaturesScreen() { - navigation.safeNavigate( - ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceFeaturesFragment( - deviceId = args.deviceId - ), - R.id.manageDeviceFragment - ) + requireActivity().execute(UpdateStateCommand.ManageDevice.Features(args.deviceId)) } override fun showManageScreen() { - navigation.safeNavigate( - ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceAdvancedFragment( - deviceId = args.deviceId - ), - R.id.manageDeviceFragment - ) + requireActivity().execute(UpdateStateCommand.ManageDevice.Advanced(args.deviceId)) } override fun showAuthenticationScreen() { @@ -126,7 +105,7 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { device -> if (device == null) { - navigation.popBackStack() + requireActivity().execute(UpdateStateCommand.ManageDevice.Leave) } else { val now = RealTime.newInstance() logic.realTimeLogic.getRealTime(now) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceAdvancedFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceAdvancedFragment.kt index a86a94f..7858ddc 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceAdvancedFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceAdvancedFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,6 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.User @@ -35,6 +34,8 @@ import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class ManageDeviceAdvancedFragment : Fragment(), FragmentWithCustomTitle { private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } @@ -47,7 +48,6 @@ class ManageDeviceAdvancedFragment : Fragment(), FragmentWithCustomTitle { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val binding = ManageDeviceAdvancedFragmentBinding.inflate(inflater, container, false) - val navigation = Navigation.findNavController(container!!) val isThisDevice = logic.deviceId.map { ownDeviceId -> ownDeviceId == args.deviceId }.ignoreUnchanged() val userEntry = deviceEntry.switchMap { device -> @@ -102,7 +102,7 @@ class ManageDeviceAdvancedFragment : Fragment(), FragmentWithCustomTitle { deviceEntry.observe(this, Observer { device -> if (device == null) { - navigation.popBackStack(R.id.overviewFragment, false) + requireActivity().execute(UpdateStateCommand.ManageDevice.Leave) } }) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceFeaturesFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceFeaturesFragment.kt index e058c45..f0e8901 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceFeaturesFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceFeaturesFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,7 +24,6 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.NetworkTime @@ -40,6 +39,8 @@ import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class ManageDeviceFeaturesFragment : Fragment(), FragmentWithCustomTitle { companion object { @@ -79,7 +80,6 @@ class ManageDeviceFeaturesFragment : Fragment(), FragmentWithCustomTitle { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val navigation = Navigation.findNavController(container!!) val binding = ManageDeviceFeaturesFragmentBinding.inflate(inflater, container, false) // auth @@ -129,7 +129,7 @@ class ManageDeviceFeaturesFragment : Fragment(), FragmentWithCustomTitle { device -> if (device == null) { - navigation.popBackStack(R.id.overviewFragment, false) + requireActivity().execute(UpdateStateCommand.ManageDevice.Leave) } else { val now = RealTime.newInstance() logic.realTimeLogic.getRealTime(now) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/ManageDevicePermissionsFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/ManageDevicePermissionsFragment.kt index 7e69867..5971198 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/ManageDevicePermissionsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/ManageDevicePermissionsFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,6 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.UserType @@ -41,6 +40,8 @@ import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class ManageDevicePermissionsFragment : Fragment(), FragmentWithCustomTitle { companion object { @@ -84,7 +85,6 @@ class ManageDevicePermissionsFragment : Fragment(), FragmentWithCustomTitle { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val navigation = Navigation.findNavController(container!!) val binding = ManageDevicePermissionsFragmentBinding.inflate(inflater, container, false) // auth @@ -180,7 +180,7 @@ class ManageDevicePermissionsFragment : Fragment(), FragmentWithCustomTitle { device -> if (device == null) { - navigation.popBackStack(R.id.overviewFragment, false) + requireActivity().execute(UpdateStateCommand.ManageDevice.Leave) } else { binding.usageStatsAccess = device.currentUsageStatsPermission binding.notificationAccessPermission = device.currentNotificationAccessPermission diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/user/ManageDeviceUserFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/user/ManageDeviceUserFragment.kt index 62d1b8c..04cfca8 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/user/ManageDeviceUserFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/user/ManageDeviceUserFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,7 +24,6 @@ import android.widget.RadioButton import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.data.model.Device import io.timelimit.android.databinding.ManageDeviceUserFragmentBinding @@ -40,6 +39,8 @@ import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle import io.timelimit.android.ui.manage.device.manage.defaultuser.ManageDeviceDefaultUser +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class ManageDeviceUserFragment : Fragment(), FragmentWithCustomTitle { private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } @@ -51,7 +52,6 @@ class ManageDeviceUserFragment : Fragment(), FragmentWithCustomTitle { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val navigation = Navigation.findNavController(container!!) val binding = ManageDeviceUserFragmentBinding.inflate(inflater, container, false) val userEntries = logic.database.user().getAllUsersLive() @@ -137,7 +137,7 @@ class ManageDeviceUserFragment : Fragment(), FragmentWithCustomTitle { device -> if (device == null) { - navigation.popBackStack(R.id.overviewFragment, false) + requireActivity().execute(UpdateStateCommand.ManageDevice.Leave) } }) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt index 9089d78..181d5d4 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,11 +24,9 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.data.model.User import io.timelimit.android.databinding.FragmentManageParentBinding -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.livedata.liveDataFromNonNullValue import io.timelimit.android.livedata.map import io.timelimit.android.logic.AppLogic @@ -40,6 +38,8 @@ import io.timelimit.android.ui.manage.child.advanced.timezone.UserTimezoneView import io.timelimit.android.ui.manage.parent.delete.DeleteParentView import io.timelimit.android.ui.manage.parent.key.ManageUserKeyView import io.timelimit.android.ui.manage.parent.limitlogin.ParentLimitLoginView +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class ManageParentFragment : Fragment(), FragmentWithCustomTitle { private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } @@ -50,7 +50,6 @@ class ManageParentFragment : Fragment(), FragmentWithCustomTitle { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val binding = FragmentManageParentBinding.inflate(inflater, container, false) - val navigation = Navigation.findNavController(container!!) val model = ViewModelProviders.of(this).get(ManageParentModel::class.java) AuthenticationFab.manageAuthenticationFab( @@ -83,9 +82,7 @@ class ManageParentFragment : Fragment(), FragmentWithCustomTitle { parentUser.observe(this, Observer { user -> - if (user == null) { - navigation.popBackStack() - } + if (user == null) requireActivity().execute(UpdateStateCommand.ManageParent.Leave) }) } @@ -140,45 +137,21 @@ class ManageParentFragment : Fragment(), FragmentWithCustomTitle { binding.handlers = object: ManageParentFragmentHandlers { override fun onChangePasswordClicked() { - navigation.safeNavigate( - ManageParentFragmentDirections. - actionManageParentFragmentToChangeParentPasswordFragment( - params.parentId - ), - R.id.manageParentFragment - ) + requireActivity().execute(UpdateStateCommand.ManageParent.ChangePassword) } override fun onRestorePasswordClicked() { - navigation.safeNavigate( - ManageParentFragmentDirections. - actionManageParentFragmentToRestoreParentPasswordFragment( - params.parentId - ), - R.id.manageParentFragment - ) + requireActivity().execute(UpdateStateCommand.ManageParent.RestorePassword) } override fun onLinkMailClicked() { if (activity.getActivityViewModel().requestAuthenticationOrReturnTrue()) { - navigation.safeNavigate( - ManageParentFragmentDirections. - actionManageParentFragmentToLinkParentMailFragment( - params.parentId - ), - R.id.manageParentFragment - ) + requireActivity().execute(UpdateStateCommand.ManageParent.LinkMail) } } override fun onManageU2FClicked() { - navigation.safeNavigate( - ManageParentFragmentDirections. - actionManageParentFragmentToManageParentU2FKeyFragment( - params.parentId - ), - R.id.manageParentFragment - ) + requireActivity().execute(UpdateStateCommand.ManageParent.U2F) } } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/link/LinkParentMailFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/link/LinkParentMailFragment.kt index cc0245a..0c20631 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/link/LinkParentMailFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/link/LinkParentMailFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,6 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.data.model.User import io.timelimit.android.databinding.LinkParentMailFragmentBinding @@ -32,6 +31,8 @@ import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.ui.authentication.AuthenticateByMailFragment import io.timelimit.android.ui.authentication.AuthenticateByMailFragmentListener import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class LinkParentMailFragment : Fragment(), AuthenticateByMailFragmentListener, FragmentWithCustomTitle { companion object { @@ -57,7 +58,6 @@ class LinkParentMailFragment : Fragment(), AuthenticateByMailFragmentListener, F override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val binding = LinkParentMailFragmentBinding.inflate(inflater, container, false) - val navigation = Navigation.findNavController(container!!) model.status.observe(this, Observer { status -> @@ -66,7 +66,7 @@ class LinkParentMailFragment : Fragment(), AuthenticateByMailFragmentListener, F LinkParentMailViewModelStatus.WaitForAuthentication -> binding.flipper.displayedChild = PAGE_LOGIN LinkParentMailViewModelStatus.WaitForConfirmationWithPassword -> binding.flipper.displayedChild = PAGE_READY LinkParentMailViewModelStatus.ShouldLeaveScreen -> { - navigation.popBackStack() + requireActivity().execute(UpdateStateCommand.ManageParent.LeaveLinkMail) null } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/change/ChangeParentPasswordFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/change/ChangeParentPasswordFragment.kt index 171fb72..5722148 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/change/ChangeParentPasswordFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/change/ChangeParentPasswordFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,7 +24,6 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders -import androidx.navigation.Navigation import com.google.android.material.snackbar.Snackbar import io.timelimit.android.R import io.timelimit.android.data.model.User @@ -33,6 +32,8 @@ import io.timelimit.android.livedata.map import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class ChangeParentPasswordFragment : Fragment(), FragmentWithCustomTitle { val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } @@ -46,14 +47,13 @@ class ChangeParentPasswordFragment : Fragment(), FragmentWithCustomTitle { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val navigation = Navigation.findNavController(container!!) val binding = ChangeParentPasswordFragmentBinding.inflate(inflater, container, false) parentUser.observe(this, Observer { parentUser -> if (parentUser == null) { - navigation.popBackStack(R.id.overviewFragment, false) + requireActivity().execute(UpdateStateCommand.ManageParent.Leave) } }) @@ -104,7 +104,7 @@ class ChangeParentPasswordFragment : Fragment(), FragmentWithCustomTitle { ChangeParentPasswordViewModelStatus.Done -> { Toast.makeText(context!!, R.string.manage_parent_change_password_toast_success, Toast.LENGTH_SHORT).show() - navigation.popBackStack() + requireActivity().execute(UpdateStateCommand.ManageParent.LeaveChangePassword) null } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/restore/RestoreParentPasswordFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/restore/RestoreParentPasswordFragment.kt index 9e29da4..c5486d2 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/restore/RestoreParentPasswordFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/restore/RestoreParentPasswordFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,7 +24,6 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.data.model.User import io.timelimit.android.databinding.RestoreParentPasswordFragmentBinding @@ -34,6 +33,8 @@ import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.ui.authentication.AuthenticateByMailFragment import io.timelimit.android.ui.authentication.AuthenticateByMailFragmentListener import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class RestoreParentPasswordFragment : Fragment(), AuthenticateByMailFragmentListener, FragmentWithCustomTitle { companion object { @@ -56,7 +57,6 @@ class RestoreParentPasswordFragment : Fragment(), AuthenticateByMailFragmentList override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val binding = RestoreParentPasswordFragmentBinding.inflate(inflater, container, false) - val navigation = Navigation.findNavController(container!!) model.status.observe(this, Observer { status -> @@ -74,14 +74,14 @@ class RestoreParentPasswordFragment : Fragment(), AuthenticateByMailFragmentList RestoreParentPasswordStatus.NetworkError -> { Toast.makeText(context!!, R.string.error_network, Toast.LENGTH_SHORT).show() - navigation.popBackStack() + requireActivity().execute(UpdateStateCommand.ManageParent.LeaveRestorePassword) null } RestoreParentPasswordStatus.Done -> { Toast.makeText(context!!, R.string.manage_parent_change_password_toast_success, Toast.LENGTH_SHORT).show() - navigation.popBackStack() + requireActivity().execute(UpdateStateCommand.ManageParent.LeaveRestorePassword) null } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/ManageParentU2FKeyFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/ManageParentU2FKeyFragment.kt index b425caf..3219a9c 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/ManageParentU2FKeyFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/ManageParentU2FKeyFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,7 +22,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.databinding.ManageParentU2fKeyFragmentBinding import io.timelimit.android.livedata.liveDataFromNonNullValue @@ -31,12 +30,13 @@ import io.timelimit.android.ui.main.* import io.timelimit.android.ui.manage.parent.u2fkey.add.AddU2FDialogFragment import io.timelimit.android.ui.manage.parent.u2fkey.remove.RemoveU2FKeyDialogFragment import io.timelimit.android.ui.manage.parent.u2fkey.remove.U2FRequiresPasswordForRemovalDialogFragment +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class ManageParentU2FKeyFragment : Fragment(), FragmentWithCustomTitle { val model: ManageParentU2FKeyModel by viewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val navigation = Navigation.findNavController(container!!) val params = ManageParentU2FKeyFragmentArgs.fromBundle(requireArguments()) val binding = ManageParentU2fKeyFragmentBinding.inflate(inflater, container, false) val activityModel = getActivityViewModel(requireActivity()) @@ -93,7 +93,9 @@ class ManageParentU2FKeyFragment : Fragment(), FragmentWithCustomTitle { } } - model.user.observe(viewLifecycleOwner) { if (it == null) navigation.popBackStack(R.id.overviewFragment, false) } + model.user.observe(viewLifecycleOwner) { + if (it == null) requireActivity().execute(UpdateStateCommand.ManageParent.Leave) + } model.listItems.observe(viewLifecycleOwner) { adapter.items = it } diff --git a/app/src/main/java/io/timelimit/android/ui/model/FragmentState.kt b/app/src/main/java/io/timelimit/android/ui/model/FragmentState.kt new file mode 100644 index 0000000..4d9f52e --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/FragmentState.kt @@ -0,0 +1,27 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.model + +import android.os.Bundle +import androidx.fragment.app.Fragment + +interface FragmentState { + val containerId: Int + val fragmentClass: Class + val arguments: Bundle get() = Bundle() + val toolbarIcons: List get() = emptyList() + val toolbarOptions: List get() = emptyList() +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/FragmentStateLegacy.kt b/app/src/main/java/io/timelimit/android/ui/model/FragmentStateLegacy.kt new file mode 100644 index 0000000..abbc479 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/FragmentStateLegacy.kt @@ -0,0 +1,12 @@ +package io.timelimit.android.ui.model + +import android.view.View +import androidx.fragment.app.Fragment + +abstract class FragmentStateLegacy( + previous: State?, + @Transient override val fragmentClass: Class, + override val containerId: Int = View.generateViewId() +): State(previous), FragmentState, java.io.Serializable { + override fun toString(): String = fragmentClass.name +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt b/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt new file mode 100644 index 0000000..e19b002 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt @@ -0,0 +1,63 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.model + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.timelimit.android.BuildConfig +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.model.launch.LaunchHandling +import io.timelimit.android.ui.model.main.OverviewHandling +import kotlinx.coroutines.flow.* + +class MainModel(application: Application): AndroidViewModel(application) { + companion object { + private const val LOG_TAG = "MainModel" + } + + private val logic = DefaultAppLogic.with(application) + + val state = MutableStateFlow(State.LaunchState as State) + + val screen: Flow = flow { + while (true) { + when (state.value) { + is State.LaunchState -> LaunchHandling.processLaunchState(state, logic) + is State.Overview -> emitAll(OverviewHandling.processState(logic, viewModelScope, state)) + is FragmentState -> emitAll(state.transformWhile { + if (it is FragmentState && it !is State.Overview) { + emit(Screen.FragmentScreen(it, it.toolbarIcons, it.toolbarOptions, it)) + + true + } else false + }) + else -> throw IllegalStateException() + } + } + } + + fun execute(command: UpdateStateCommand) { + state.update { oldState -> + command.transform(oldState) ?: oldState.also { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "execute($command) did not transform state") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/extensions/Navigation.kt b/app/src/main/java/io/timelimit/android/ui/model/MainModelActivity.kt similarity index 64% rename from app/src/main/java/io/timelimit/android/extensions/Navigation.kt rename to app/src/main/java/io/timelimit/android/ui/model/MainModelActivity.kt index c7ad5b6..81cd71e 100644 --- a/app/src/main/java/io/timelimit/android/extensions/Navigation.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/MainModelActivity.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -13,13 +13,14 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package io.timelimit.android.extensions +package io.timelimit.android.ui.model -import androidx.navigation.NavController -import androidx.navigation.NavDirections +import android.app.Activity -fun NavController.safeNavigate(directions: NavDirections, currentScreen: Int) { - if (this.currentDestination?.id == currentScreen) { - navigate(directions) - } +interface MainModelActivity { + fun execute(command: UpdateStateCommand) } + +fun Activity.execute(command: UpdateStateCommand) { + (this as MainModelActivity).execute(command) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/Menu.kt b/app/src/main/java/io/timelimit/android/ui/model/Menu.kt new file mode 100644 index 0000000..caca395 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/Menu.kt @@ -0,0 +1,8 @@ +package io.timelimit.android.ui.model + +import androidx.compose.ui.graphics.vector.ImageVector + +object Menu { + data class Icon(val icon: ImageVector, val labelResource: Int, val action: UpdateStateCommand) + data class Dropdown(val labelResource: Int, val action: UpdateStateCommand) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/Screen.kt b/app/src/main/java/io/timelimit/android/ui/model/Screen.kt new file mode 100644 index 0000000..5f18eaa --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/Screen.kt @@ -0,0 +1,52 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.model + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import io.timelimit.android.R +import io.timelimit.android.ui.model.main.OverviewHandling + +sealed class Screen( + val state: State, + val toolbarIcons: List = emptyList(), + val toolbarOptions: List = emptyList() +) { + open class FragmentScreen( + state: State, + toolbarIcons: List, + toolbarOptions: List, + val fragment: FragmentState + ): Screen(state, toolbarIcons, toolbarOptions) + + class OverviewScreen( + state: State, + val content: OverviewHandling.OverviewScreen + ): Screen( + state, + listOf(Menu.Icon( + Icons.Outlined.Info, + R.string.main_tab_about, + UpdateStateCommand.Overview.LaunchAbout + )), + listOf(Menu.Dropdown( + R.string.main_tab_uninstall, + UpdateStateCommand.Overview.Uninstall + )) + ), ScreenWithAuthenticationFab +} + +interface ScreenWithAuthenticationFab \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/State.kt b/app/src/main/java/io/timelimit/android/ui/model/State.kt new file mode 100644 index 0000000..efba97b --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/State.kt @@ -0,0 +1,254 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.model + +import android.os.Bundle +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DirectionsBike +import androidx.compose.material.icons.filled.Phone +import androidx.fragment.app.Fragment +import io.timelimit.android.R +import io.timelimit.android.ui.contacts.ContactsFragment +import io.timelimit.android.ui.diagnose.* +import io.timelimit.android.ui.diagnose.exitreason.DiagnoseExitReasonFragment +import io.timelimit.android.ui.fragment.* +import io.timelimit.android.ui.manage.category.ManageCategoryFragment +import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs +import io.timelimit.android.ui.manage.child.ManageChildFragment +import io.timelimit.android.ui.manage.child.ManageChildFragmentArgs +import io.timelimit.android.ui.manage.device.manage.ManageDeviceFragment +import io.timelimit.android.ui.manage.device.manage.ManageDeviceFragmentArgs +import io.timelimit.android.ui.manage.device.manage.advanced.ManageDeviceAdvancedFragment +import io.timelimit.android.ui.manage.device.manage.advanced.ManageDeviceAdvancedFragmentArgs +import io.timelimit.android.ui.manage.device.manage.feature.ManageDeviceFeaturesFragment +import io.timelimit.android.ui.manage.device.manage.feature.ManageDeviceFeaturesFragmentArgs +import io.timelimit.android.ui.manage.device.manage.permission.ManageDevicePermissionsFragment +import io.timelimit.android.ui.manage.device.manage.permission.ManageDevicePermissionsFragmentArgs +import io.timelimit.android.ui.manage.device.manage.user.ManageDeviceUserFragment +import io.timelimit.android.ui.manage.device.manage.user.ManageDeviceUserFragmentArgs +import io.timelimit.android.ui.manage.parent.ManageParentFragment +import io.timelimit.android.ui.manage.parent.ManageParentFragmentArgs +import io.timelimit.android.ui.manage.parent.link.LinkParentMailFragment +import io.timelimit.android.ui.manage.parent.link.LinkParentMailFragmentArgs +import io.timelimit.android.ui.manage.parent.password.change.ChangeParentPasswordFragment +import io.timelimit.android.ui.manage.parent.password.change.ChangeParentPasswordFragmentArgs +import io.timelimit.android.ui.manage.parent.password.restore.RestoreParentPasswordFragment +import io.timelimit.android.ui.manage.parent.password.restore.RestoreParentPasswordFragmentArgs +import io.timelimit.android.ui.manage.parent.u2fkey.ManageParentU2FKeyFragment +import io.timelimit.android.ui.manage.parent.u2fkey.ManageParentU2FKeyFragmentArgs +import io.timelimit.android.ui.model.main.OverviewHandling +import io.timelimit.android.ui.overview.uninstall.UninstallFragment +import io.timelimit.android.ui.parentmode.ParentModeFragment +import io.timelimit.android.ui.payment.PurchaseFragment +import io.timelimit.android.ui.payment.StayAwesomeFragment +import io.timelimit.android.ui.setup.* +import io.timelimit.android.ui.setup.child.SetupRemoteChildFragment +import io.timelimit.android.ui.setup.device.SetupDeviceFragment +import io.timelimit.android.ui.setup.parent.SetupParentModeFragment +import io.timelimit.android.ui.user.create.AddUserFragment +import java.io.Serializable + +sealed class State (val previous: State?): Serializable { + fun hasPrevious(other: State): Boolean = this.previous == other || this.previous?.hasPrevious(other) ?: false + fun find(predicate: (State) -> Boolean): State? = + if (predicate(this)) this + else previous?.find(predicate) + fun first(): State = previous?.first() ?: this + object LaunchState: State(previous = null) + data class Overview( + val state: OverviewHandling.OverviewState = OverviewHandling.OverviewState.empty + ): State(previous = null) + class About(previous: Overview): FragmentStateLegacy(previous = previous, fragmentClass = AboutFragmentWrapped::class.java) + class AddUser(previous: Overview): FragmentStateLegacy(previous = previous, fragmentClass = AddUserFragment::class.java) + sealed class ManageChild(previous: State, fragmentClass: Class): FragmentStateLegacy(previous, fragmentClass) { + class Main( + previous: Overview, + val childId: String, + fromRedirect: Boolean + ): ManageChild(previous = previous, ManageChildFragment::class.java) { + @Transient + override val arguments = ManageChildFragmentArgs(childId = childId, fromRedirect = fromRedirect).toBundle() + + override val toolbarIcons: List = listOf( + Menu.Icon( + Icons.Default.DirectionsBike, + R.string.manage_child_tasks, + UpdateStateCommand.ManageChild.Tasks + ), + Menu.Icon( + Icons.Default.Phone, + R.string.contacts_title_long, + UpdateStateCommand.ManageChild.Contacts + ) + ) + + override val toolbarOptions: List = listOf( + Menu.Dropdown(R.string.child_apps_title, UpdateStateCommand.ManageChild.Apps), + Menu.Dropdown(R.string.usage_history_title, UpdateStateCommand.ManageChild.UsageHistory), + Menu.Dropdown(R.string.manage_child_tab_other, UpdateStateCommand.ManageChild.Advanced) + ) + } + + class Apps(val previousChild: Main): ManageChild(previousChild, ChildAppsFragmentWrapper::class.java) { + @Transient + override val arguments: Bundle = ChildAppsFragmentWrapperArgs(previousChild.childId).toBundle() + } + class Advanced(val previousChild: Main): ManageChild(previousChild, ChildAdvancedFragmentWrapper::class.java) { + @Transient + override val arguments: Bundle = ChildAdvancedFragmentWrapperArgs(previousChild.childId).toBundle() + } + class Contacts(val previousChild: Main): ManageChild(previousChild, ContactsFragment::class.java) + class UsageHistory(val previousChild: Main): ManageChild(previousChild, ChildUsageHistoryFragmentWrapper::class.java) { + @Transient + override val arguments: Bundle = ChildUsageHistoryFragmentWrapperArgs(previousChild.childId).toBundle() + } + class Tasks(val previousChild: Main): ManageChild(previousChild, ChildTasksFragmentWrapper::class.java) { + @Transient + override val arguments: Bundle = ChildTasksFragmentWrapperArgs(previousChild.childId).toBundle() + } + + sealed class ManageCategory(previous: State, fragmentClass: Class): ManageChild(previous, fragmentClass) { + class Main( + val previousChild: ManageChild.Main, + val categoryId: String + ): ManageCategory(previous = previousChild, fragmentClass = ManageCategoryFragment::class.java) { + @Transient + override val arguments: Bundle = ManageCategoryFragmentArgs( + childId = previousChild.childId, + categoryId = categoryId + ).toBundle() + + override val toolbarOptions: List = listOf( + Menu.Dropdown(R.string.blocked_time_areas, UpdateStateCommand.ManageChild.BlockedTimes), + Menu.Dropdown(R.string.category_settings, UpdateStateCommand.ManageChild.CategoryAdvanced) + ) + } + + class BlockedTimes( + val previousCategory: Main + ): ManageCategory(previous = previousCategory, fragmentClass = BlockedTimeAreasFragmentWrapper::class.java) { + @Transient + override val arguments: Bundle = BlockedTimeAreasFragmentWrapperArgs( + childId = previousCategory.previousChild.childId, + categoryId = previousCategory.categoryId + ).toBundle() + } + + class Advanced( + val previousCategory: Main + ): ManageCategory(previous = previousCategory, fragmentClass = CategoryAdvancedFragmentWrapper::class.java) { + @Transient + override val arguments: Bundle = CategoryAdvancedFragmentWrapperArgs( + childId = previousCategory.previousChild.childId, + categoryId = previousCategory.categoryId + ).toBundle() + } + } + } + sealed class ManageParent(previous: State, fragmentClass: Class): FragmentStateLegacy(previous = previous, fragmentClass = fragmentClass) { + class Main( + previous: Overview, + val parentId: String + ): ManageParent(previous = previous, fragmentClass = ManageParentFragment::class.java) { + @Transient + override val arguments = ManageParentFragmentArgs(parentId).toBundle() + } + + class ChangePassword(val previousParent: Main): ManageParent(previousParent, ChangeParentPasswordFragment::class.java) { + @Transient + override val arguments: Bundle = ChangeParentPasswordFragmentArgs(previousParent.parentId).toBundle() + } + class RestorePassword(val previousParent: Main): ManageParent(previousParent, RestoreParentPasswordFragment::class.java) { + @Transient + override val arguments: Bundle = RestoreParentPasswordFragmentArgs(previousParent.parentId).toBundle() + } + class LinkMail(val previousParent: Main): ManageParent(previousParent, LinkParentMailFragment::class.java) { + @Transient + override val arguments: Bundle = LinkParentMailFragmentArgs(previousParent.parentId).toBundle() + } + class U2F(val previousParent: Main): ManageParent(previousParent, ManageParentU2FKeyFragment::class.java) { + @Transient + override val arguments: Bundle = ManageParentU2FKeyFragmentArgs(previousParent.parentId).toBundle() + } + } + sealed class ManageDevice( + previous: State, + fragmentClass: Class + ): FragmentStateLegacy(previous, fragmentClass) { + class Main( + val previousOverview: Overview, + deviceId: String + ): ManageDevice(previousOverview, ManageDeviceFragment::class.java) { + @Transient + override val arguments: Bundle = ManageDeviceFragmentArgs(deviceId).toBundle() + } + class User( + val previousMain: Main, + deviceId: String + ): ManageDevice(previousMain, ManageDeviceUserFragment::class.java) { + @Transient + override val arguments: Bundle = ManageDeviceUserFragmentArgs(deviceId).toBundle() + } + class Permissions( + val previousMain: Main, + deviceId: String + ): ManageDevice(previousMain, ManageDevicePermissionsFragment::class.java) { + @Transient + override val arguments: Bundle = ManageDevicePermissionsFragmentArgs(deviceId).toBundle() + } + class Features( + val previousMain: Main, + deviceId: String + ): ManageDevice(previousMain, ManageDeviceFeaturesFragment::class.java) { + @Transient + override val arguments: Bundle = ManageDeviceFeaturesFragmentArgs(deviceId).toBundle() + } + class Advanced( + val previousMain: Main, + deviceId: String + ): ManageDevice(previousMain, ManageDeviceAdvancedFragment::class.java) { + @Transient + override val arguments: Bundle = ManageDeviceAdvancedFragmentArgs(deviceId).toBundle() + } + } + class SetupDevice(val previousOverview: Overview): FragmentStateLegacy(previous = previousOverview, fragmentClass = SetupDeviceFragment::class.java) + class Uninstall(previous: Overview): FragmentStateLegacy(previous = previous, fragmentClass = UninstallFragment::class.java) + object DiagnoseScreen { + class Main(previous: About): FragmentStateLegacy(previous, DiagnoseMainFragment::class.java) + class Battery(previous: Main): FragmentStateLegacy(previous, DiagnoseBatteryFragment::class.java) + class Clock(previous: Main): FragmentStateLegacy(previous, DiagnoseClockFragment::class.java) + class Connection(previous: Main): FragmentStateLegacy(previous, DiagnoseConnectionFragment::class.java) + class ExperimentalFlags(previous: Main): FragmentStateLegacy(previous, DiagnoseExperimentalFlagFragment::class.java) + class ExitReasons(previous: Main): FragmentStateLegacy(previous, DiagnoseExitReasonFragment::class.java) + class Crypto(previous: Main): FragmentStateLegacy(previous, DiagnoseCryptoFragment::class.java) + class ForegroundApp(previous: Main): FragmentStateLegacy(previous, DiagnoseForegroundAppFragment::class.java) + class Sync(previous: Main): FragmentStateLegacy(previous, DiagnoseSyncFragment::class.java) + } + object Setup { + class SetupTerms: FragmentStateLegacy(previous = null, fragmentClass = SetupTermsFragment::class.java) + class SetupHelpInfo(previous: SetupTerms): FragmentStateLegacy(previous = previous, fragmentClass = SetupHelpInfoFragment::class.java) + class SelectMode(previous: SetupHelpInfo): FragmentStateLegacy(previous = previous, fragmentClass = SetupSelectModeFragment::class.java) + class DevicePermissions(previous: SelectMode): FragmentStateLegacy(previous = previous, fragmentClass = SetupDevicePermissionsFragment::class.java) + class LocalMode(previous: DevicePermissions): FragmentStateLegacy(previous = previous, fragmentClass = SetupLocalModeFragment::class.java) + class RemoteChild(previous: SelectMode): FragmentStateLegacy(previous = previous, fragmentClass = SetupRemoteChildFragment::class.java) + class ParentMode(previous: SelectMode): FragmentStateLegacy(previous = previous, fragmentClass = SetupParentModeFragment::class.java) + } + class ParentMode: FragmentStateLegacy(previous = null, fragmentClass = ParentModeFragment::class.java) + object Purchase { + class Purchase(previous: About): FragmentStateLegacy(previous, PurchaseFragment::class.java) + class StayAwesome(previous: About): FragmentStateLegacy(previous, StayAwesomeFragment::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/UpdateStateCommand.kt b/app/src/main/java/io/timelimit/android/ui/model/UpdateStateCommand.kt new file mode 100644 index 0000000..fa416c2 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/UpdateStateCommand.kt @@ -0,0 +1,395 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.model + +import io.timelimit.android.ui.model.main.OverviewHandling + +sealed class UpdateStateCommand { + abstract fun transform(state: State): State? + + object Reset: UpdateStateCommand() { + override fun transform(state: State) = State.LaunchState + } + object BackToPreviousScreen: UpdateStateCommand() { + override fun transform(state: State): State? = state.previous + } + object Launch { + object LaunchOverview: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.LaunchState) State.Overview() + else null + } + class LaunchChild(val childId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.LaunchState) State.ManageChild.Main( + childId = childId, + fromRedirect = true, + previous = State.Overview() + ) + else null + } + object LaunchDeviceSetup: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.LaunchState) State.SetupDevice(State.Overview()) + else null + } + + object LaunchTerms: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.LaunchState) State.Setup.SetupTerms() + else null + } + object LaunchParentMode: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.LaunchState) State.ParentMode() + else null + } + } + + object Overview { + object LaunchAbout: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Overview) State.About(state) + else null + } + object AddUser: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Overview) State.AddUser(state) + else null + } + data class ManageChild(val childId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Overview) State.ManageChild.Main(state, childId, fromRedirect = false) + else null + } + data class ManageParent(val childId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Overview) State.ManageParent.Main(state, childId) + else null + } + data class ManageDevice(val childId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Overview) State.ManageDevice.Main(state, childId) + else null + } + object SetupDevice: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Overview) State.SetupDevice(state) + else null + } + + object Uninstall: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Overview) State.Uninstall(state) + else null + } + + object ShowAllUsers: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Overview) state.copy( + state = state.state.copy(showAllUsers = true) + ) + else null + } + + class ShowMoreDevices(val deviceList: OverviewHandling.OverviewState.DeviceList): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Overview) state.copy( + state = state.state.copy(visibleDevices = deviceList) + ) + else null + } + } + object About { + object Diagnose: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.About) State.DiagnoseScreen.Main(state) + else null + } + + object Purchase: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.About) State.Purchase.Purchase(state) + else null + } + + object StayAwesome: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.About) State.Purchase.StayAwesome(state) + else null + } + } + object AddUser { + object Leave: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.AddUser) state.previous + else null + } + } + + object ManageDevice { + data class User(val childId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageDevice.Main) State.ManageDevice.User(state, childId) + else null + } + data class Permissions(val childId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageDevice.Main) State.ManageDevice.Permissions(state, childId) + else null + } + data class Features(val childId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageDevice.Main) State.ManageDevice.Features(state, childId) + else null + } + data class Advanced(val childId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageDevice.Main) State.ManageDevice.Advanced(state, childId) + else null + } + object Leave: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageDevice) when (state) { + is State.ManageDevice.Main -> state.previousOverview + is State.ManageDevice.User -> state.previousMain.previousOverview + is State.ManageDevice.Permissions -> state.previousMain.previousOverview + is State.ManageDevice.Features -> state.previousMain.previousOverview + is State.ManageDevice.Advanced -> state.previousMain.previousOverview + } + else null + } + class EnterFromDeviceSetup(val deviceId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.SetupDevice) State.ManageDevice.Main( + deviceId = deviceId, + previousOverview = state.previousOverview + ) + else null + } + } + object ManageChild { + object Apps: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild.Main) State.ManageChild.Apps(state) + else null + } + object Advanced: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild.Main) State.ManageChild.Advanced(state) + else null + } + object Contacts: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild.Main) State.ManageChild.Contacts(state) + else null + } + object UsageHistory: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild.Main) State.ManageChild.UsageHistory(state) + else null + } + object Tasks: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild.Main) State.ManageChild.Tasks(state) + else null + } + + class Category(val childId: String, val categoryId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild.Main && state.childId == childId) State.ManageChild.ManageCategory.Main(previousChild = state, categoryId = categoryId) + else null + } + + object BlockedTimes: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild.ManageCategory.Main) State.ManageChild.ManageCategory.BlockedTimes(state) + else null + } + + object CategoryAdvanced: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild.ManageCategory.Main) State.ManageChild.ManageCategory.Advanced(state) + else null + } + + object LeaveCategory: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild.ManageCategory) when (state) { + is State.ManageChild.ManageCategory.Main -> state.previous + is State.ManageChild.ManageCategory.Advanced -> state.previousCategory.previous + is State.ManageChild.ManageCategory.BlockedTimes -> state.previousCategory.previous + } + else null + } + + object LeaveChild: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageChild) when (state) { + is State.ManageChild.Main -> state.previous + is State.ManageChild.Apps -> state.previousChild.previous + is State.ManageChild.Advanced -> state.previousChild.previous + is State.ManageChild.Contacts -> state.previousChild.previous + is State.ManageChild.Tasks -> state.previousChild.previous + is State.ManageChild.UsageHistory -> state.previousChild.previous + is State.ManageChild.ManageCategory.Main -> state.previousChild.previous + is State.ManageChild.ManageCategory.Advanced -> state.previousCategory.previousChild.previous + is State.ManageChild.ManageCategory.BlockedTimes -> state.previousCategory.previousChild.previous + } + else null + } + } + object ManageParent { + object ChangePassword: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageParent.Main) State.ManageParent.ChangePassword(state) + else null + } + object RestorePassword: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageParent.Main) State.ManageParent.RestorePassword(state) + else null + } + object LinkMail: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageParent.Main) State.ManageParent.LinkMail(state) + else null + } + object U2F: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageParent.Main) State.ManageParent.U2F(state) + else null + } + object Leave: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageParent) when (state) { + is State.ManageParent.Main -> state.previous + is State.ManageParent.ChangePassword -> state.previousParent.previous + is State.ManageParent.U2F -> state.previousParent.previous + is State.ManageParent.LinkMail -> state.previousParent.previous + is State.ManageParent.RestorePassword -> state.previousParent.previous + } + else null + } + + object LeaveRestorePassword: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageParent.RestorePassword) state.previous + else null + } + + object LeaveChangePassword: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageParent.ChangePassword) state.previous + else null + } + + object LeaveLinkMail: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.ManageParent.LinkMail) state.previous + else null + } + } + object Diagnose { + object Battery: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.DiagnoseScreen.Main) State.DiagnoseScreen.Battery(state) + else null + } + object Clock: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.DiagnoseScreen.Main) State.DiagnoseScreen.Clock(state) + else null + } + object Connection: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.DiagnoseScreen.Main) State.DiagnoseScreen.Connection(state) + else null + } + object Crypto: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.DiagnoseScreen.Main) State.DiagnoseScreen.Crypto(state) + else null + } + object ExperimentalFlags: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.DiagnoseScreen.Main) State.DiagnoseScreen.ExperimentalFlags(state) + else null + } + object ExitReasons: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.DiagnoseScreen.Main) State.DiagnoseScreen.ExitReasons(state) + else null + } + object ForegroundApp: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.DiagnoseScreen.Main) State.DiagnoseScreen.ForegroundApp(state) + else null + } + object Sync: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.DiagnoseScreen.Main) State.DiagnoseScreen.Sync(state) + else null + } + } + object Setup { + object Help: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Setup.SetupTerms) State.Setup.SetupHelpInfo(state) + else null + } + + object SelectMode: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Setup.SetupHelpInfo) State.Setup.SelectMode(state) + else null + } + + object DevicePermissions: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Setup.SelectMode) State.Setup.DevicePermissions(state) + else null + } + + object LocalMode: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Setup.DevicePermissions) State.Setup.LocalMode(state) + else null + } + + object RemoteChild: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Setup.SelectMode) State.Setup.RemoteChild(state) + else null + } + + object ParentMode: UpdateStateCommand() { + override fun transform(state: State): State? = + if (state is State.Setup.SelectMode) State.Setup.ParentMode(state) + else null + } + } + + class RecoverPassword(val parentId: String): UpdateStateCommand() { + override fun transform(state: State): State? = + state.find { it is State.Overview }?.let { overview -> + State.ManageParent.Main( + parentId = parentId, + previous = overview as State.Overview + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/launch/LaunchHandling.kt b/app/src/main/java/io/timelimit/android/ui/model/launch/LaunchHandling.kt new file mode 100644 index 0000000..474ff7a --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/launch/LaunchHandling.kt @@ -0,0 +1,46 @@ +package io.timelimit.android.ui.model.launch + +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.data.model.UserType +import io.timelimit.android.livedata.waitUntilValueMatches +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.ui.model.State +import kotlinx.coroutines.flow.MutableStateFlow + +object LaunchHandling { + suspend fun processLaunchState( + state: MutableStateFlow, + logic: AppLogic + ) { + val oldValue = state.value + + if (oldValue is State.LaunchState) { + state.compareAndSet(oldValue, getInitialState(logic)) + } + } + + private suspend fun getInitialState(logic: AppLogic): State { + logic.isInitialized.waitUntilValueMatches { it == true } + + // TODO: readd the obsolete dialog fragment + return Threads.database.executeAndWait { + val hasDeviceId = logic.database.config().getOwnDeviceIdSync() != null + val hasParentKey = logic.database.config().getParentModeKeySync() != null + + if (hasDeviceId) { + val config = logic.database.derivedDataDao().getUserAndDeviceRelatedDataSync() + val overview = State.Overview() + + if (config?.userRelatedData?.user?.type == UserType.Child) State.ManageChild.Main( + previous = overview, + childId = config.userRelatedData.user.id, + fromRedirect = true + ) + else if (config?.userRelatedData == null) State.SetupDevice(overview) + else overview + } else if (hasParentKey) State.ParentMode() + else State.Setup.SetupTerms() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/main/OverviewHandling.kt b/app/src/main/java/io/timelimit/android/ui/model/main/OverviewHandling.kt new file mode 100644 index 0000000..991ddec --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/main/OverviewHandling.kt @@ -0,0 +1,299 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.model.main + +import androidx.lifecycle.asFlow +import io.timelimit.android.BuildConfig +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.data.model.Device +import io.timelimit.android.data.model.HintsToShow +import io.timelimit.android.data.model.UserType +import io.timelimit.android.data.model.derived.FullChildTask +import io.timelimit.android.extensions.whileTrue +import io.timelimit.android.integration.platform.RuntimePermissionStatus +import io.timelimit.android.livedata.map +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.logic.ServerApiLevelInfo +import io.timelimit.android.ui.model.Screen +import io.timelimit.android.ui.model.State +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.distinctUntilChanged +import java.util.* + +object OverviewHandling { + fun processState(logic: AppLogic, scope: CoroutineScope, stateLive: MutableStateFlow): Flow { + val actions: Actions = getActions(logic, scope, stateLive) + val overviewStateLive: Flow = stateLive.transform { if (it is State.Overview) emit(it.state) } + val overviewState2Live: Flow = stateLive.transform { if (it is State.Overview) emit(it) } + val overviewScreenLive: Flow = getScreen(logic, actions, overviewStateLive) + val hasMatchingStateLive = stateLive.map { it is State.Overview }.distinctUntilChanged() + + return hasMatchingStateLive.whileTrue { + overviewState2Live.combine(overviewScreenLive) { state, overviewScreen -> + Screen.OverviewScreen(state, overviewScreen) + } + } + } + + private fun getActions(logic: AppLogic, scope: CoroutineScope, stateLive: MutableStateFlow): Actions = Actions( + hideIntro = { + scope.launch { + Threads.database.executeAndWait { + logic.database.config().setHintsShownSync(HintsToShow.OVERVIEW_INTRODUCTION) + } + } + }, + skipTaskReview = { task -> + stateLive.update { oldState -> + if (oldState is State.Overview) oldState.copy( + state = oldState.state.copy( + hiddenTaskIds = oldState.state.hiddenTaskIds + task.task.childTask.taskId + ) + ) + else oldState + } + } + ) + + private fun getScreen(logic: AppLogic, actions: Actions, state: Flow): Flow { + val introLive = getIntroFlags(logic) + val taskToReviewLive = getTaskToReview(logic, state.map { it.hiddenTaskIds }) + val usersLive = getUserList(logic, state.map { it.showAllUsers }) + val devicesLive = getDeviceList(logic, state.map { it.visibleDevices }) + + return combine( + introLive, taskToReviewLive, usersLive, devicesLive + ) { intro, taskToReview, users, devices -> + OverviewScreen( + intro = intro, + taskToReview = taskToReview, + users = users, + devices = devices, + actions = actions + ) + } + } + + private fun getUsers(logic: AppLogic): Flow> { + val userFlow = logic.database.user().getAllUsersFlow() + + val timeFlow = flow { + while (true) { + emit(logic.realTimeLogic.getCurrentTimeInMillis()) + delay(5000) + } + } + + return userFlow.combine(timeFlow) { users, time -> + users.map { user -> + UserItem( + id = user.id, + name = user.name, + type = user.type, + areLimitsTemporarilyDisabled = user.disableLimitsUntil >= time, + viewingNeedsAuthentication = user.restrictViewingToParents + ) + } + }.distinctUntilChanged() + } + + private fun getUserList(logic: AppLogic, showAllUsersLive: Flow): Flow { + val usersLive = getUsers(logic) + + return usersLive.combine(showAllUsersLive) { users, showAllUsers -> + if (showAllUsers || users.none { it.type != UserType.Parent }) UserList( + list = users, + canAdd = true, + canShowMore = false + ) + else UserList( + list = users.filter { it.type == UserType.Child }, + canAdd = false, + canShowMore = true + ) + } + } + + private fun getDevices(logic: AppLogic): Flow> { + val ownDeviceIdLive = logic.database.config().getOwnDeviceIdFlow() + val devicesWithUserNameLive = logic.database.device().getAllDevicesWithUserInfoFlow() + val connectedDevicesLive = logic.websocket.connectedDevices.asFlow() + + return combine ( + ownDeviceIdLive, devicesWithUserNameLive, connectedDevicesLive + ) { ownDeviceId, devicesWithUserName, connectedDevices -> + devicesWithUserName.map { deviceWithUserName -> + DeviceItem( + device = deviceWithUserName.device, + userName = deviceWithUserName.currentUserName, + userType = deviceWithUserName.currentUserType, + isCurrentDevice = deviceWithUserName.device.id == ownDeviceId, + isConnected = connectedDevices.contains(deviceWithUserName.device.id) + ) + } + } + } + + private fun getDeviceList(logic: AppLogic, deviceListLive: Flow): Flow { + val devicesLive = getDevices(logic) + + return devicesLive.combine(deviceListLive) { allDevices, deviceList -> + val bareMinimum = allDevices.filter { it.isCurrentDevice || it.device.isImportant } + val allChildDevices = allDevices.filter { it.isCurrentDevice || it.device.isImportant || it.userType == UserType.Child } + + val list = when (deviceList) { + OverviewState.DeviceList.BareMinimum -> bareMinimum + OverviewState.DeviceList.AllChildDevices -> allChildDevices + OverviewState.DeviceList.AllDevices -> allDevices + } + + val canShowMore = when (deviceList) { + OverviewState.DeviceList.BareMinimum -> + if (bareMinimum.size != allChildDevices.size) OverviewState.DeviceList.AllChildDevices + else if (bareMinimum.size != allDevices.size) OverviewState.DeviceList.AllDevices + else null + OverviewState.DeviceList.AllChildDevices -> + if (allChildDevices.size != allDevices.size) OverviewState.DeviceList.AllDevices + else null + OverviewState.DeviceList.AllDevices -> null + } + + val canAdd = list.size == allDevices.size + + DeviceList( + list = list, + canAdd = canAdd, + canShowMore = canShowMore + ) + } + } + + private fun getIntroFlags(logic: AppLogic): Flow { + val showSetupOptionLive = logic.deviceUserEntry.map { it == null }.asFlow() + + val serverVersionLive = logic.serverApiLevelLogic.infoLive.asFlow() + val showOutdatedServerLive = serverVersionLive.map { serverVersion -> + !serverVersion.hasLevelOrIsOffline(BuildConfig.minimumRecommendServerVersion) + } + + val showServerMessageLive = logic.database.config().getServerMessageFlow() + + val hasShownIntroductionLive = logic.database.config().wereHintsShown(HintsToShow.OVERVIEW_INTRODUCTION).asFlow() + val showIntroLive = hasShownIntroductionLive.map { !it } + + return combine( + showSetupOptionLive, showOutdatedServerLive, showServerMessageLive, showIntroLive + ) { showSetupOption, showOutdatedServer, showServerMessage, showIntro -> + IntroFlags( + showSetupOption = showSetupOption, + showOutdatedServer = showOutdatedServer, + showServerMessage = showServerMessage, + showIntro = showIntro + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getTaskToReview(logic: AppLogic, hiddenTaskIdsLive: Flow>): Flow { + val pendingTasksLive = logic.database.childTasks().getPendingTasksFlow() + val serverApiLevelLive = logic.serverApiLevelLogic.infoLive.asFlow() + val hasPremiumLive = logic.fullVersion.shouldProvideFullVersionFunctions.asFlow() + + val taskWithChildLive = pendingTasksLive.combine(hiddenTaskIdsLive) { pendingTasks, hiddenTaskIds -> + pendingTasks + .filterNot { hiddenTaskIds.contains(it.childTask.taskId) } + .firstOrNull() + }.transformLatest { + if (it != null) { + emitAll(logic.database.user().getChildUserByIdLive(it.childId).asFlow().map { childInfo -> + if (childInfo == null) null + else Pair(it, childInfo.timeZone) + }) + } else emit(null) + } + + return combine( + serverApiLevelLive, hasPremiumLive, taskWithChildLive + ) { serverApiLevel, hasPremium, taskWithChild -> + if (taskWithChild == null) null + else TaskToReview( + task = taskWithChild.first, + childTimezone = TimeZone.getTimeZone(taskWithChild.second), + serverApiLevel = serverApiLevel, + hasPremium = hasPremium + ) + } + } + + data class OverviewState( + val hiddenTaskIds: Set, + val visibleDevices: DeviceList, + val showAllUsers: Boolean + ): java.io.Serializable { + companion object { + val empty = OverviewState( + hiddenTaskIds = emptySet(), + visibleDevices = DeviceList.BareMinimum, + showAllUsers = false + ) + } + + enum class DeviceList { + BareMinimum, // current device + devices with warnings + AllChildDevices, + AllDevices + } + } + + data class OverviewScreen( + val intro: IntroFlags, + val taskToReview: TaskToReview?, + val users: UserList, + val devices: DeviceList, + val actions: Actions + ) + data class Actions( + val hideIntro: () -> Unit, + val skipTaskReview: (TaskToReview) -> Unit + ) + data class IntroFlags( + val showSetupOption: Boolean, + val showOutdatedServer: Boolean, + val showServerMessage: String?, + val showIntro: Boolean + ) + data class TaskToReview( + val task: FullChildTask, + val hasPremium: Boolean, + val childTimezone: TimeZone, + val serverApiLevel: ServerApiLevelInfo + ) + data class UserItem( + val id: String, + val name: String, + val type: UserType, + val areLimitsTemporarilyDisabled: Boolean, + val viewingNeedsAuthentication: Boolean + ) + data class UserList(val list: List, val canAdd: Boolean, val canShowMore: Boolean) + data class DeviceItem(val device: Device, val userName: String?, val userType: UserType?, val isCurrentDevice: Boolean, val isConnected: Boolean) { + val isMissingRequiredPermission = userType == UserType.Child && ( + device.currentUsageStatsPermission == RuntimePermissionStatus.NotGranted || device.missingPermissionAtQOrLater) + } + data class DeviceList(val list: List, val canAdd: Boolean, val canShowMore: OverviewState.DeviceList?) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/overview/main/MainFragment.kt b/app/src/main/java/io/timelimit/android/ui/overview/main/MainFragment.kt deleted file mode 100644 index 8675f4e..0000000 --- a/app/src/main/java/io/timelimit/android/ui/overview/main/MainFragment.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.overview.main - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.fragment.app.Fragment -import androidx.lifecycle.LiveData -import io.timelimit.android.R -import io.timelimit.android.extensions.safeNavigate -import io.timelimit.android.livedata.* -import io.timelimit.android.ui.fragment.SingleFragmentWrapper -import io.timelimit.android.ui.main.FragmentWithCustomTitle -import io.timelimit.android.ui.manage.device.add.AddDeviceFragment -import io.timelimit.android.ui.overview.about.AboutFragmentParentHandlers -import io.timelimit.android.ui.overview.overview.OverviewFragment -import io.timelimit.android.ui.overview.overview.OverviewFragmentParentHandlers - -class MainFragment : SingleFragmentWrapper(), OverviewFragmentParentHandlers, AboutFragmentParentHandlers, FragmentWithCustomTitle { - override val showAuthButton: Boolean = true - - override fun createChildFragment(): Fragment = OverviewFragment() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setHasOptionsMenu(true) - } - - override fun openAddDeviceScreen() { - AddDeviceFragment().show(parentFragmentManager) - } - - override fun openAddUserScreen() { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToAddUserFragment(), - R.id.overviewFragment - ) - } - - override fun openManageChildScreen(childId: String) { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToManageChildFragment(childId = childId, fromRedirect = false), - R.id.overviewFragment - ) - } - - override fun openManageDeviceScreen(deviceId: String) { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToManageDeviceFragment(deviceId), - R.id.overviewFragment - ) - } - - override fun onShowPurchaseScreen() { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToPurchaseFragment(), - R.id.overviewFragment - ) - } - - override fun onShowStayAwesomeScreen() { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToStayAwesomeFragment(), - R.id.overviewFragment - ) - } - - override fun openManageParentScreen(parentId: String) { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToManageParentFragment(parentId), - R.id.overviewFragment - ) - } - - override fun openSetupDeviceScreen() { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToSetupDeviceFragment(), - R.id.overviewFragment - ) - } - - override fun onShowDiagnoseScreen() { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToDiagnoseMainFragment(), - R.id.overviewFragment - ) - } - - override fun getCustomTitle(): LiveData = liveDataFromNullableValue("${getString(R.string.main_tab_overview)} (${getString(R.string.app_name)})") - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - - inflater.inflate(R.menu.fragment_main_menu, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - R.id.menu_main_about -> { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToAboutFragmentWrapped(), - R.id.overviewFragment - ) - - true - } - R.id.menu_main_uninstall -> { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToUninstallFragment(), - R.id.overviewFragment - ) - - true - } - else -> super.onOptionsItemSelected(item) - } -} diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/Device.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/Device.kt new file mode 100644 index 0000000..ac38645 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/Device.kt @@ -0,0 +1,133 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.overview.overview + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.timelimit.android.BuildConfig +import io.timelimit.android.R +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.main.OverviewHandling + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyItemScope.DeviceItem( + item: OverviewHandling.DeviceItem, + executeCommand: (UpdateStateCommand) -> Unit +) { + ListCardCommon.Card( + Modifier + .animateItemPlacement() + .padding(horizontal = 8.dp) + .clickable( + onClick = { + executeCommand(UpdateStateCommand.Overview.ManageDevice(item.device.id)) + } + ) + ) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.Smartphone, + label = stringResource(R.string.overview_device_item_name), + value = item.device.name, + style = MaterialTheme.typography.h6 + ) + + if (item.userName != null) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.AccountCircle, + label = stringResource(R.string.overview_device_item_user_name), + value = item.userName + ) + } + + if (item.device.isUserKeptSignedIn) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.LockOpen, + label = stringResource(R.string.overview_device_item_password_disabled), + value = stringResource(R.string.overview_device_item_password_disabled), + multiline = true + ) + } + + if (item.isConnected) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.Wifi, + label = stringResource(R.string.overview_device_item_connected), + value = stringResource(R.string.overview_device_item_connected), + multiline = true + ) + } + + if (item.device.currentAppVersion < BuildConfig.VERSION_CODE) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.Update, + label = stringResource(R.string.overview_device_item_older_version), + value = stringResource(R.string.overview_device_item_older_version), + tint = MaterialTheme.colors.primary, + multiline = true + ) + } + + if (item.device.hasAnyManipulation) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.Warning, + label = stringResource(R.string.overview_device_item_manipulation), + value = stringResource(R.string.overview_device_item_manipulation), + tint = MaterialTheme.colors.error, + multiline = true + ) + } + + if (item.isMissingRequiredPermission) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.Warning, + label = stringResource(R.string.overview_device_item_missing_permission), + value = stringResource(R.string.overview_device_item_missing_permission), + tint = MaterialTheme.colors.error, + multiline = true + ) + } + + if (item.device.didReportUninstall) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.Warning, + label = stringResource(R.string.overview_device_item_uninstall), + value = stringResource(R.string.overview_device_item_uninstall), + tint = MaterialTheme.colors.error, + multiline = true + ) + } + + if (item.isCurrentDevice) { + ListCardCommon.TextWithIcon( + icon = null, + label = stringResource(R.string.manage_device_is_this_device), + value = stringResource(R.string.manage_device_is_this_device), + style = MaterialTheme.typography.subtitle1, + multiline = true + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/ListCardCommon.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/ListCardCommon.kt new file mode 100644 index 0000000..b73b942 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/ListCardCommon.kt @@ -0,0 +1,89 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.overview.overview + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +object ListCardCommon { + @Composable + fun Card( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit + ) { + androidx.compose.material.Card( + modifier = modifier + ) { + Column(modifier = Modifier.padding(8.dp)) { + content() + } + } + } + + @Composable + fun TextWithIcon( + icon: ImageVector?, + label: String, + value: String, + style: TextStyle = LocalTextStyle.current, + tint: Color = Color.Unspecified, + multiline: Boolean = false + ) { + Row { + if (icon != null) Icon(icon, label, tint = tint) + else Spacer(Modifier.size(24.dp)) + + Spacer(Modifier.width(8.dp)) + + Text( + value, + modifier = Modifier.weight(1.0f), + maxLines = if (multiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + style = style, + color = tint + ) + } + } + + @Composable + fun ActionButton( + label: String, + action: () -> Unit + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End + ) { + Button( + onClick = action + ) { + Text(label) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/ListCommon.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/ListCommon.kt new file mode 100644 index 0000000..d72571b --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/ListCommon.kt @@ -0,0 +1,84 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.overview.overview + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.timelimit.android.R + +object ListCommon { + @Composable + fun SectionHeader(text: String, modifier: Modifier = Modifier) { + Text( + text, + textAlign = TextAlign.Center, + modifier = modifier.fillMaxWidth(), + style = MaterialTheme.typography.h5 + ) + } + + @Composable + fun ActionListItem( + icon: ImageVector, + label: String, + action: () -> Unit, + modifier: Modifier = Modifier + ) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable( + onClickLabel = label, + onClick = action + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, label) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + label, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + @Composable + fun ShowMoreItem(modifier: Modifier = Modifier, action: () -> Unit) { + ActionListItem( + icon = Icons.Default.ExpandMore, + label = stringResource(R.string.generic_show_more), + action = action, + modifier = modifier + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt deleted file mode 100644 index fb62be2..0000000 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.overview.overview - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import io.timelimit.android.async.Threads -import io.timelimit.android.coroutines.CoroutineFragment -import io.timelimit.android.data.model.* -import io.timelimit.android.databinding.FragmentOverviewBinding -import io.timelimit.android.date.DateInTimezone -import io.timelimit.android.livedata.waitForNonNullValue -import io.timelimit.android.logic.AppLogic -import io.timelimit.android.logic.DefaultAppLogic -import io.timelimit.android.logic.ServerApiLevelInfo -import io.timelimit.android.sync.actions.ReviewChildTaskAction -import io.timelimit.android.ui.main.ActivityViewModel -import io.timelimit.android.ui.main.getActivityViewModel -import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment -import kotlinx.coroutines.launch -import java.util.* - -class OverviewFragment : CoroutineFragment() { - private val handlers: OverviewFragmentParentHandlers by lazy { parentFragment as OverviewFragmentParentHandlers } - private val logic: AppLogic by lazy { DefaultAppLogic.with(requireContext()) } - private val auth: ActivityViewModel by lazy { getActivityViewModel(requireActivity()) } - private val model: OverviewFragmentModel by viewModels() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val binding = FragmentOverviewBinding.inflate(inflater, container, false) - val adapter = OverviewFragmentAdapter() - - binding.recycler.adapter = adapter - binding.recycler.layoutManager = LinearLayoutManager(requireContext()) - - adapter.handlers = object: OverviewFragmentHandlers { - override fun onAddUserClicked() { - handlers.openAddUserScreen() - } - - override fun onDeviceClicked(device: Device) { - handlers.openManageDeviceScreen(deviceId = device.id) - } - - override fun onUserClicked(user: User) { - if ( - user.restrictViewingToParents && - logic.deviceUserId.value != user.id && - !auth.requestAuthenticationOrReturnTrue() - ) { - // do "nothing"/ request authentication - } else { - when (user.type) { - UserType.Child -> handlers.openManageChildScreen(childId = user.id) - UserType.Parent -> handlers.openManageParentScreen(parentId = user.id) - }.let { } - } - } - - override fun onAddDeviceClicked() { - launch { - if (logic.database.config().getDeviceAuthTokenAsync().waitForNonNullValue().isEmpty()) { - CanNotAddDevicesInLocalModeDialogFragment() - .apply { setTargetFragment(this@OverviewFragment, 0) } - .show(fragmentManager!!) - } else if (auth.requestAuthenticationOrReturnTrue()) { - handlers.openAddDeviceScreen() - } - } - } - - override fun onFinishSetupClicked() { - handlers.openSetupDeviceScreen() - } - - override fun onShowAllUsersClicked() { - model.showAllUsers() - } - - override fun onSetDeviceListVisibility(level: DeviceListItemVisibility) { - model.showMoreDevices(level) - } - - override fun onTaskConfirmed(task: ChildTask, hasPremium: Boolean, timezone: TimeZone, serverApiLevel: ServerApiLevelInfo) { - if (hasPremium) { - val time = logic.timeApi.getCurrentTimeInMillis() - val day = DateInTimezone.newInstance(time, timezone).dayOfEpoch - - auth.tryDispatchParentAction( - ReviewChildTaskAction( - taskId = task.taskId, - ok = true, - time = time, - day = if (serverApiLevel.hasLevelOrIsOffline(2)) day else null - ) - ) - } else RequiresPurchaseDialogFragment().show(parentFragmentManager) - } - - override fun onTaskRejected(task: ChildTask) { - auth.tryDispatchParentAction( - ReviewChildTaskAction( - taskId = task.taskId, - ok = false, - time = logic.timeApi.getCurrentTimeInMillis(), - day = null - ) - ) - } - - override fun onSkipTaskReviewClicked(task: ChildTask) { - if (auth.requestAuthenticationOrReturnTrue()) model.hideTask(task.taskId) - } - } - - model.listEntries.observe(viewLifecycleOwner) { adapter.data = it } - - ItemTouchHelper( - object: ItemTouchHelper.Callback() { - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - val index = viewHolder.adapterPosition - val item = if (index == RecyclerView.NO_POSITION) null else adapter.data!![index] - - if (item == OverviewFragmentHeaderIntro) { - return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START or ItemTouchHelper.END) or - makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END or ItemTouchHelper.END) - } else { - return 0 - } - } - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder) = throw IllegalStateException() - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - // remove the introduction header - Threads.database.execute { - logic.database.config().setHintsShownSync(HintsToShow.OVERVIEW_INTRODUCTION) - } - } - } - ).attachToRecyclerView(binding.recycler) - - return binding.root - } -} - -interface OverviewFragmentParentHandlers { - fun openAddUserScreen() - fun openAddDeviceScreen() - fun openManageDeviceScreen(deviceId: String) - fun openManageChildScreen(childId: String) - fun openManageParentScreen(parentId: String) - fun openSetupDeviceScreen() -} diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentAdapter.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentAdapter.kt deleted file mode 100644 index a0f6bdd..0000000 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentAdapter.kt +++ /dev/null @@ -1,330 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.overview.overview - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import io.timelimit.android.BuildConfig -import io.timelimit.android.R -import io.timelimit.android.data.model.ChildTask -import io.timelimit.android.data.model.Device -import io.timelimit.android.data.model.User -import io.timelimit.android.data.model.UserType -import io.timelimit.android.databinding.* -import io.timelimit.android.logic.ServerApiLevelInfo -import io.timelimit.android.ui.util.DateUtil -import io.timelimit.android.util.TimeTextUtil -import java.util.* -import kotlin.properties.Delegates - -class OverviewFragmentAdapter : RecyclerView.Adapter() { - init { - setHasStableIds(true) - } - - var data: List? by Delegates.observable(null as List?) { _, _, _ -> notifyDataSetChanged() } - var handlers: OverviewFragmentHandlers? = null - - fun getItem(index: Int): OverviewFragmentItem { - return data!![index] - } - - override fun getItemId(position: Int): Long { - val item = getItem(position) - - return when (item) { - is OverviewFragmentItemDevice -> "device ${item.device.id}".hashCode().toLong() - is OverviewFragmentItemUser -> "user ${item.user.id}".hashCode().toLong() - is TaskReviewOverviewItem -> "task ${item.task.taskId}".hashCode().toLong() - else -> item.hashCode().toLong() - } - } - - override fun getItemCount(): Int { - val data = this.data - - if (data == null) { - return 0 - } else { - return data.size - } - } - - private fun getItemType(item: OverviewFragmentItem): OverviewFragmentViewType = when(item) { - is OverviewFragmentHeaderUsers -> OverviewFragmentViewType.Header - is OverviewFragmentHeaderDevices -> OverviewFragmentViewType.Header - is OverviewFragmentItemUser -> OverviewFragmentViewType.UserItem - is OverviewFragmentItemDevice -> OverviewFragmentViewType.DeviceItem - is OverviewFragmentActionAddUser -> OverviewFragmentViewType.AddUserItem - is OverviewFragmentActionAddDevice -> OverviewFragmentViewType.AddDeviceItem - is OverviewFragmentHeaderIntro -> OverviewFragmentViewType.Introduction - is OverviewFragmentHeaderFinishSetup -> OverviewFragmentViewType.FinishSetup - is OverviewFragmentItemMessage -> OverviewFragmentViewType.ServerMessage - is ShowMoreOverviewFragmentItem -> OverviewFragmentViewType.ShowMoreButton - is TaskReviewOverviewItem -> OverviewFragmentViewType.TaskReview - is OverviewFragmentItemOutdatedServer -> OverviewFragmentViewType.ServerMessage - } - - override fun getItemViewType(position: Int) = getItemType(getItem(position)).ordinal - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when(viewType) { - OverviewFragmentViewType.Header.ordinal -> - HeaderViewHolder( - GenericListHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) - - OverviewFragmentViewType.UserItem.ordinal -> - UserViewHolder( - FragmentOverviewUserItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - - OverviewFragmentViewType.DeviceItem.ordinal -> - DeviceViewHolder( - FragmentOverviewDeviceItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - - OverviewFragmentViewType.AddUserItem.ordinal -> - AddUserViewHolder( - AddItemViewBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ).apply { - label = parent.context.getString(R.string.add_user_title) - - root.setOnClickListener { - handlers?.onAddUserClicked() - } - }.root - ) - - OverviewFragmentViewType.AddDeviceItem.ordinal -> AddDeviceViewHolder( - AddItemViewBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ).apply { - label = parent.context.getString(R.string.overview_add_device) - - root.setOnClickListener { - handlers?.onAddDeviceClicked() - } - }.root - ) - - OverviewFragmentViewType.Introduction.ordinal -> IntroViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.fragment_overview_intro, parent, false) - ) - - OverviewFragmentViewType.FinishSetup.ordinal -> FinishSetupViewHolder( - FragmentOverviewFinishSetupBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ).apply { - btnGo.setOnClickListener { handlers?.onFinishSetupClicked() } - }.root - ) - - OverviewFragmentViewType.ServerMessage.ordinal -> ServerMessageViewHolder( - FragmentOverviewServerMessageBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - - OverviewFragmentViewType.ShowMoreButton.ordinal -> ShowMoreViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.show_more_list_item, parent, false) - ) - - OverviewFragmentViewType.TaskReview.ordinal -> TaskReviewHolder( - FragmentOverviewTaskReviewBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) - - else -> throw IllegalStateException() - } - - override fun onBindViewHolder(holder: OverviewFragmentViewHolder, position: Int) { - val context = holder.itemView.context - val item = getItem(position) - - when (item) { - is OverviewFragmentHeaderUsers -> { - if (holder !is HeaderViewHolder) { - throw IllegalStateException() - } - - holder.header.text = holder.itemView.context.getString(R.string.overview_header_users) - holder.header.executePendingBindings() - } - is OverviewFragmentHeaderDevices -> { - if (holder !is HeaderViewHolder) { - throw IllegalStateException() - } - - holder.header.text = holder.itemView.context.getString(R.string.overview_header_devices) - holder.header.executePendingBindings() - } - is OverviewFragmentItemUser -> { - if (holder !is UserViewHolder) { - throw IllegalStateException() - } - - val binding = holder.binding - - binding.username = item.user.name - binding.areTimeLimitsDisabled = item.limitsTemporarilyDisabled - binding.isTemporarilyBlocked = item.temporarilyBlocked - binding.isParent = item.user.type == UserType.Parent - binding.isChild = item.user.type == UserType.Child - - binding.card.setOnClickListener { - this.handlers?.onUserClicked(item.user) - } - - binding.executePendingBindings() - } - is OverviewFragmentItemDevice -> { - if (holder !is DeviceViewHolder) { - throw IllegalStateException() - } - - val binding = holder.binding - - binding.deviceTitle = item.device.name - binding.currentDeviceUserTitle = item.deviceUser?.name - binding.hasManipulation = item.device.hasAnyManipulation - binding.isCurrentDevice = item.isCurrentDevice - binding.isMissingRequiredPermission = item.isMissingRequiredPermission - binding.didUninstall = item.device.didReportUninstall - binding.isPasswordDisabled = item.device.isUserKeptSignedIn - binding.isConnected = item.isConnected - binding.isUsingOlderVersion = item.device.currentAppVersion < BuildConfig.VERSION_CODE - binding.executePendingBindings() - - binding.card.setOnClickListener { - this.handlers?.onDeviceClicked(item.device) - } - } - is OverviewFragmentActionAddUser -> { /* nothing to do */ } - is OverviewFragmentActionAddDevice-> { /* nothing to do */ } - is OverviewFragmentHeaderIntro -> { /* nothing to do */ } - is OverviewFragmentHeaderFinishSetup -> { /* nothing to do */ } - is OverviewFragmentItemMessage -> { - holder as ServerMessageViewHolder - - holder.binding.title = context.getString(R.string.overview_server_message) - holder.binding.text = item.message - holder.binding.executePendingBindings() - } - is ShowMoreOverviewFragmentItem -> { - holder as ShowMoreViewHolder - - when (item) { - is ShowMoreOverviewFragmentItem.ShowAllUsers -> { - holder.itemView.setOnClickListener { handlers?.onShowAllUsersClicked() } - } - is ShowMoreOverviewFragmentItem.ShowMoreDevices -> { - holder.itemView.setOnClickListener { handlers?.onSetDeviceListVisibility(item.level) } - } - }.let { } - } - is TaskReviewOverviewItem -> { - holder as TaskReviewHolder - - holder.binding.let { - it.categoryTitle = item.categoryTitle - it.childName = item.childTitle - it.duration = TimeTextUtil.time(item.task.extraTimeDuration, it.root.context) - it.lastGrant = if (item.task.lastGrantTimestamp == 0L) null else DateUtil.formatAbsoluteDate(it.root.context, item.task.lastGrantTimestamp) - it.taskTitle = item.task.taskTitle - - it.yesButton.setOnClickListener { - handlers?.onTaskConfirmed( - task = item.task, - hasPremium = item.hasPremium, - timezone = item.childTimezone, - serverApiLevel = item.serverApiLevel - ) - } - - it.noButton.setOnClickListener { handlers?.onTaskRejected(item.task) } - it.skipButton.setOnClickListener { handlers?.onSkipTaskReviewClicked(item.task) } - } - - holder.binding.executePendingBindings() - } - is OverviewFragmentItemOutdatedServer -> { - holder as ServerMessageViewHolder - - holder.binding.title = context.getString(R.string.overview_server_outdated_title) - holder.binding.text = context.getString(R.string.overview_server_outdated_text) - holder.binding.executePendingBindings() - } - }.let { } - } -} - -enum class OverviewFragmentViewType { - Header, - UserItem, - DeviceItem, - AddUserItem, - AddDeviceItem, - Introduction, - FinishSetup, - ServerMessage, - ShowMoreButton, - TaskReview -} - -sealed class OverviewFragmentViewHolder(view: View): RecyclerView.ViewHolder(view) -class HeaderViewHolder(val header: GenericListHeaderBinding): OverviewFragmentViewHolder(header.root) -class AddUserViewHolder(view: View): OverviewFragmentViewHolder(view) -class UserViewHolder(val binding: FragmentOverviewUserItemBinding): OverviewFragmentViewHolder(binding.root) -class DeviceViewHolder(val binding: FragmentOverviewDeviceItemBinding): OverviewFragmentViewHolder(binding.root) -class AddDeviceViewHolder(view: View): OverviewFragmentViewHolder(view) -class IntroViewHolder(view: View): OverviewFragmentViewHolder(view) -class FinishSetupViewHolder(view: View): OverviewFragmentViewHolder(view) -class ServerMessageViewHolder(val binding: FragmentOverviewServerMessageBinding): OverviewFragmentViewHolder(binding.root) -class ShowMoreViewHolder(view: View): OverviewFragmentViewHolder(view) -class TaskReviewHolder(val binding: FragmentOverviewTaskReviewBinding): OverviewFragmentViewHolder(binding.root) - -interface OverviewFragmentHandlers { - fun onAddUserClicked() - fun onAddDeviceClicked() - fun onUserClicked(user: User) - fun onDeviceClicked(device: Device) - fun onFinishSetupClicked() - fun onShowAllUsersClicked() - fun onSetDeviceListVisibility(level: DeviceListItemVisibility) - fun onSkipTaskReviewClicked(task: ChildTask) - fun onTaskConfirmed(task: ChildTask, hasPremium: Boolean, timezone: TimeZone, serverApiLevel: ServerApiLevelInfo) - fun onTaskRejected(task: ChildTask) -} diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt deleted file mode 100644 index 2b6e13a..0000000 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.overview.overview - -import io.timelimit.android.data.model.ChildTask -import io.timelimit.android.data.model.Device -import io.timelimit.android.data.model.User -import io.timelimit.android.data.model.UserType -import io.timelimit.android.integration.platform.RuntimePermissionStatus -import io.timelimit.android.logic.ServerApiLevelInfo -import java.util.* - -sealed class OverviewFragmentItem -object OverviewFragmentHeaderUsers: OverviewFragmentItem() -object OverviewFragmentHeaderDevices: OverviewFragmentItem() -data class OverviewFragmentItemDevice(val device: Device, val deviceUser: User?, val isCurrentDevice: Boolean, val isConnected: Boolean): OverviewFragmentItem() { - val isMissingRequiredPermission = deviceUser?.type == UserType.Child && ( - device.currentUsageStatsPermission == RuntimePermissionStatus.NotGranted || device.missingPermissionAtQOrLater - ) - - val isImportant get() = device.isImportant -} -data class OverviewFragmentItemUser(val user: User, val temporarilyBlocked: Boolean, val limitsTemporarilyDisabled: Boolean): OverviewFragmentItem() -object OverviewFragmentActionAddUser: OverviewFragmentItem() -object OverviewFragmentActionAddDevice: OverviewFragmentItem() -object OverviewFragmentHeaderIntro: OverviewFragmentItem() -object OverviewFragmentHeaderFinishSetup: OverviewFragmentItem() -data class OverviewFragmentItemMessage(val message: String): OverviewFragmentItem() -object OverviewFragmentItemOutdatedServer: OverviewFragmentItem() -sealed class ShowMoreOverviewFragmentItem: OverviewFragmentItem() { - object ShowAllUsers: ShowMoreOverviewFragmentItem() - data class ShowMoreDevices(val level: DeviceListItemVisibility): ShowMoreOverviewFragmentItem() -} -data class TaskReviewOverviewItem( - val task: ChildTask, - val childTitle: String, - val categoryTitle: String, - val hasPremium: Boolean, - val childTimezone: TimeZone, - val serverApiLevel: ServerApiLevelInfo -): OverviewFragmentItem() \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentModel.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentModel.kt deleted file mode 100644 index ed6b7cf..0000000 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentModel.kt +++ /dev/null @@ -1,205 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.overview.overview - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import io.timelimit.android.BuildConfig -import io.timelimit.android.data.model.HintsToShow -import io.timelimit.android.data.model.UserType -import io.timelimit.android.livedata.* -import io.timelimit.android.logic.DefaultAppLogic -import java.util.* - -class OverviewFragmentModel(application: Application): AndroidViewModel(application) { - private val logic = DefaultAppLogic.with(application) - - private val itemVisibility = MutableLiveData().apply { value = OverviewItemVisibility.default } - - private val categoryEntries = logic.database.category().getAllCategoriesShortInfo() - private val usersWithTemporarilyDisabledLimits = logic.database.user().getAllUsersLive().switchMap { - users -> - - liveDataFromFunction { logic.realTimeLogic.getCurrentTimeInMillis() }.map { - currentTime -> - - users.map { - user -> - - user to (user.disableLimitsUntil >= currentTime) - } - } - }.ignoreUnchanged() - private val userEntries = usersWithTemporarilyDisabledLimits.switchMap { users -> - categoryEntries.switchMap { categories -> - liveDataFromFunction (5000) { logic.realTimeLogic.getCurrentTimeInMillis() }.map { now -> - users.map { user -> - OverviewFragmentItemUser( - user = user.first, - limitsTemporarilyDisabled = user.second, - temporarilyBlocked = categories.find { category -> - category.childId == user.first.id && - category.temporarilyBlocked && ( - category.temporarilyBlockedEndTime == 0L || - category.temporarilyBlockedEndTime > now - ) - } != null - ) - } - } - }.ignoreUnchanged() - } - - private val ownDeviceId = logic.deviceId - private val devices = logic.database.device().getAllDevicesLive() - private val devicesWithUsers = devices.switchMap { devices -> - usersWithTemporarilyDisabledLimits.map { users -> - devices.map { device -> - device to users.find { it.first.id == device.currentUserId } - } - } - } - private val deviceEntries = ownDeviceId.switchMap { thisDeviceId -> - devicesWithUsers.switchMap { devices -> - logic.websocket.connectedDevices.map { connectedDevices -> - devices.map { (device, user) -> - OverviewFragmentItemDevice( - device = device, - deviceUser = user?.first, - isCurrentDevice = device.id == thisDeviceId, - isConnected = connectedDevices.contains(device.id) - ) - } - } - } - } - - private val isNoUserAssignedLive = logic.deviceUserEntry.map { it == null }.ignoreUnchanged() - private val hasShownIntroduction = logic.database.config().wereHintsShown(HintsToShow.OVERVIEW_INTRODUCTION) - private val messageLive = logic.database.config().getServerMessage() - private val serverVersion = logic.serverApiLevelLogic.infoLive - private val introEntries = mergeLiveDataWaitForValues(isNoUserAssignedLive, hasShownIntroduction, messageLive, serverVersion) - .map { (noUserAssigned, hasShownIntro, message, serverVersion) -> - val result = mutableListOf() - - if (noUserAssigned) { - result.add(OverviewFragmentHeaderFinishSetup) - } - - if (!serverVersion.hasLevelOrIsOffline(BuildConfig.minimumRecommendServerVersion)) { - result.add(OverviewFragmentItemOutdatedServer) - } - - if (message != null) { - result.add(OverviewFragmentItemMessage(message)) - } - - if (!hasShownIntro) { - result.add(OverviewFragmentHeaderIntro) - } - - result - } - - private val hiddenTaskIdsLive = MutableLiveData>().apply { value = emptySet() } - private val tasksWithPendingReviewLive = logic.database.childTasks().getPendingTasks() - private val pendingTasksToShowLive = hiddenTaskIdsLive.switchMap { hiddenTaskIds -> - tasksWithPendingReviewLive.map { tasksWithPendingReview -> - tasksWithPendingReview.filterNot { hiddenTaskIds.contains(it.childTask.taskId) } - } - } - private val hasPremiumLive = logic.fullVersion.shouldProvideFullVersionFunctions - private val pendingTaskItemLive = hasPremiumLive.switchMap { hasPremium -> - logic.serverApiLevelLogic.infoLive.switchMap { serverApiLevel -> - pendingTasksToShowLive.map { tasks -> - tasks.firstOrNull()?.let { - TaskReviewOverviewItem( - task = it.childTask, - childTitle = it.childName, - categoryTitle = it.categoryTitle, - hasPremium = hasPremium, - childTimezone = TimeZone.getTimeZone(it.childTimezone), - serverApiLevel = serverApiLevel - ) - } - } - } - } - - fun hideTask(taskId: String) { - hiddenTaskIdsLive.value = (hiddenTaskIdsLive.value ?: emptySet()) + setOf(taskId) - } - - val listEntries = introEntries.switchMap { introEntries -> - deviceEntries.switchMap { deviceEntries -> - userEntries.switchMap { userEntries -> - pendingTaskItemLive.switchMap { pendingTaskItem -> - itemVisibility.map { itemVisibility -> - mutableListOf().apply { - addAll(introEntries) - - if (pendingTaskItem != null) add(pendingTaskItem) - - add(OverviewFragmentHeaderDevices) - val shownDevices = when (itemVisibility.devices) { - DeviceListItemVisibility.BareMinimum -> deviceEntries.filter { it.isCurrentDevice || it.isImportant } - DeviceListItemVisibility.AllChildDevices -> deviceEntries.filter { it.isCurrentDevice || it.isImportant || it.deviceUser?.type == UserType.Child } - DeviceListItemVisibility.AllDevices -> deviceEntries - } - addAll(shownDevices) - if (shownDevices.size == deviceEntries.size) { - add(OverviewFragmentActionAddDevice) - } else { - add(ShowMoreOverviewFragmentItem.ShowMoreDevices(when (itemVisibility.devices) { - DeviceListItemVisibility.BareMinimum -> run { - if ( - deviceEntries.any { - !it.isCurrentDevice && - !it.isImportant && - it.deviceUser?.type == UserType.Child - } - ) DeviceListItemVisibility.AllChildDevices else DeviceListItemVisibility.AllDevices - } - DeviceListItemVisibility.AllChildDevices -> DeviceListItemVisibility.AllDevices - DeviceListItemVisibility.AllDevices -> DeviceListItemVisibility.AllDevices - })) - } - - add(OverviewFragmentHeaderUsers) - userEntries.forEach { if (it.user.type != UserType.Parent) add(it) } - if (itemVisibility.showParentUsers || userEntries.all { it.user.type == UserType.Parent }) { - userEntries.forEach { if (it.user.type == UserType.Parent) add(it) } - add(OverviewFragmentActionAddUser) - } else { - add(ShowMoreOverviewFragmentItem.ShowAllUsers) - } - }.toList() - } - } as LiveData> - } - } - } - - fun showAllUsers() { - itemVisibility.value = itemVisibility.value!!.copy(showParentUsers = true) - } - - fun showMoreDevices(level: DeviceListItemVisibility) { - itemVisibility.value = itemVisibility.value!!.copy(devices = level) - } -} diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewItemVisibility.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewItemVisibility.kt deleted file mode 100644 index 0f98f97..0000000 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewItemVisibility.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.overview.overview - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class OverviewItemVisibility( - val showParentUsers: Boolean, - val devices: DeviceListItemVisibility -) : Parcelable { - companion object { - val default = OverviewItemVisibility( - showParentUsers = false, - devices = DeviceListItemVisibility.BareMinimum - ) - } -} - -enum class DeviceListItemVisibility { - BareMinimum, // current device + devices with warnings - AllChildDevices, - AllDevices -} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewScreen.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewScreen.kt new file mode 100644 index 0000000..a6262ff --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewScreen.kt @@ -0,0 +1,287 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.overview.overview + +import androidx.compose.foundation.ExperimentalFoundationApi +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.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import io.timelimit.android.R +import io.timelimit.android.date.DateInTimezone +import io.timelimit.android.livedata.waitForNonNullValue +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.sync.actions.ReviewChildTaskAction +import io.timelimit.android.ui.MainActivity +import io.timelimit.android.ui.manage.device.add.AddDeviceFragment +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.main.OverviewHandling +import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment +import io.timelimit.android.util.TimeTextUtil +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) +@Composable +fun OverviewScreen( + screen: OverviewHandling.OverviewScreen, + executeCommand: (UpdateStateCommand) -> Unit, + modifier: Modifier = Modifier +) { + val activity = LocalContext.current as MainActivity + + LazyColumn ( + contentPadding = PaddingValues(0.dp, 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + if (screen.intro.showSetupOption) { + item (key = Pair("intro", "finish setup")) { + ListCardCommon.Card( + modifier = Modifier + .animateItemPlacement() + .padding(horizontal = 8.dp) + ) { + Text( + stringResource(R.string.overview_finish_setup_title), + style = MaterialTheme.typography.h6 + ) + + Text(stringResource(R.string.overview_finish_setup_text)) + + ListCardCommon.ActionButton( + label = stringResource(R.string.generic_go), + action = { + executeCommand(UpdateStateCommand.Overview.SetupDevice) + } + ) + } + } + } + + if (screen.intro.showOutdatedServer) { + item (key = Pair("intro", "outdated server")) { + ListCardCommon.Card( + modifier = Modifier + .animateItemPlacement() + .padding(horizontal = 8.dp) + ) { + Text( + stringResource(R.string.overview_server_outdated_title), + style = MaterialTheme.typography.h6 + ) + + Text(stringResource(R.string.overview_server_outdated_text)) + } + } + } + + if (screen.intro.showServerMessage != null) { + item (key = Pair("intro", "servermessage")) { + ListCardCommon.Card( + modifier = Modifier + .animateItemPlacement() + .padding(horizontal = 8.dp) + ) { + Text( + stringResource(R.string.overview_server_message), + style = MaterialTheme.typography.h6 + ) + + Text(screen.intro.showServerMessage) + } + } + } + + if (screen.intro.showIntro) { + item (key = Pair("intro", "intro")) { + val state = remember { + DismissState( + initialValue = DismissValue.Default, + confirmStateChange = { + screen.actions.hideIntro() + + true + } + ) + } + + SwipeToDismiss( + state = state, + background = {}, + modifier = Modifier.animateItemPlacement() + ) { + ListCardCommon.Card( + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text( + stringResource(R.string.overview_intro_title), + style = MaterialTheme.typography.h6 + ) + + Text(stringResource(R.string.overview_intro_text)) + + Text( + stringResource(R.string.generic_swipe_to_dismiss), + style = MaterialTheme.typography.subtitle1 + ) + } + } + } + } + + if (screen.taskToReview != null) { + item (key = Pair("task", "review")) { + ListCardCommon.Card( + modifier = Modifier + .animateItemPlacement() + .padding(horizontal = 8.dp) + ) { + Text( + stringResource(R.string.task_review_title), + style = MaterialTheme.typography.h6 + ) + + Text( + stringResource(R.string.task_review_text, screen.taskToReview.task.childName, screen.taskToReview.task.childTask.taskTitle) + ) + + Text( + stringResource( + R.string.task_review_category, + TimeTextUtil.time(screen.taskToReview.task.childTask.extraTimeDuration, LocalContext.current), + screen.taskToReview.task.categoryTitle + ), + style = MaterialTheme.typography.subtitle1 + ) + + Row { + val auth = activity.getActivityViewModel() + val logic = auth.logic + + TextButton(onClick = { + if (activity.getActivityViewModel().isParentAuthenticated()) { + screen.actions.skipTaskReview(screen.taskToReview) + } else activity.showAuthenticationScreen() + }) { + Text(stringResource(R.string.generic_skip)) + } + + Spacer(Modifier.weight(1.0f)) + + OutlinedButton(onClick = { + if (activity.getActivityViewModel().isParentAuthenticated()) { + auth.tryDispatchParentAction( + ReviewChildTaskAction( + taskId = screen.taskToReview.task.childTask.taskId, + ok = false, + time = logic.timeApi.getCurrentTimeInMillis(), + day = null + ) + ) + } else activity.showAuthenticationScreen() + }) { + Text(stringResource(R.string.generic_no)) + } + + Spacer(Modifier.width(8.dp)) + + OutlinedButton(onClick = { + if (activity.getActivityViewModel().isParentAuthenticated()) { + if (screen.taskToReview.hasPremium) { + val time = logic.timeApi.getCurrentTimeInMillis() + val day = DateInTimezone.newInstance(time, screen.taskToReview.childTimezone).dayOfEpoch + + auth.tryDispatchParentAction( + ReviewChildTaskAction( + taskId = screen.taskToReview.task.childTask.taskId, + ok = true, + time = time, + day = if (screen.taskToReview.serverApiLevel.hasLevelOrIsOffline(2)) day else null + ) + ) + } else RequiresPurchaseDialogFragment().show(activity.supportFragmentManager) + } else activity.showAuthenticationScreen() + }) { + Text(stringResource(R.string.generic_yes)) + } + } + + Text( + stringResource(R.string.purchase_required_info_local_mode_free), + style = MaterialTheme.typography.subtitle1 + ) + } + } + } + + item (key = Pair("devices", "header")) { ListCommon.SectionHeader(stringResource(R.string.overview_header_devices), Modifier.animateItemPlacement()) } + items(screen.devices.list, key = { Pair("device", it.device.id) }) { + DeviceItem(it, executeCommand) + } + if (screen.devices.canAdd) { + // TODO: implement this without dependency on MainActivity + item (key = Pair("devices", "add")) { + ListCommon.ActionListItem( + icon = Icons.Default.Add, + label = stringResource(R.string.add_device), + action = { + activity.lifecycleScope.launch { + val logic = DefaultAppLogic.with(activity) + + if (logic.database.config().getDeviceAuthTokenAsync() + .waitForNonNullValue().isEmpty() + ) { + CanNotAddDevicesInLocalModeDialogFragment() + .show(activity.supportFragmentManager) + } else if (activity.getActivityViewModel().requestAuthenticationOrReturnTrue()) { + AddDeviceFragment().show(activity.supportFragmentManager) + } + } + }, + modifier = Modifier.animateItemPlacement() + ) + } + } + if (screen.devices.canShowMore != null) { + item (key = Pair("devices", "show more")) { ListCommon.ShowMoreItem(modifier = Modifier.animateItemPlacement()) { + executeCommand(UpdateStateCommand.Overview.ShowMoreDevices(screen.devices.canShowMore)) + }} + } + + item (key = Pair("header", "users")) { ListCommon.SectionHeader(stringResource(R.string.overview_header_users), Modifier.animateItemPlacement()) } + items(screen.users.list, key = { Pair("user", it.id) }) { UserItem(it, executeCommand) } + if (screen.users.canAdd) item (key = Pair("header", "user.create")) { + ListCommon.ActionListItem( + icon = Icons.Default.Add, + label = stringResource(R.string.add_user_title), + action = { executeCommand(UpdateStateCommand.Overview.AddUser) }, + modifier = Modifier.animateItemPlacement() + ) + } + if (screen.users.canShowMore) item (key = Pair("header", "user.more")) { + ListCommon.ShowMoreItem (modifier = Modifier.animateItemPlacement()) { executeCommand(UpdateStateCommand.Overview.ShowAllUsers) } + } + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/User.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/User.kt new file mode 100644 index 0000000..8ca6bfe --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/User.kt @@ -0,0 +1,94 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.overview.overview + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.AlarmOff +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.timelimit.android.R +import io.timelimit.android.data.model.UserType +import io.timelimit.android.ui.MainActivity +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.main.OverviewHandling + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyItemScope.UserItem( + user: OverviewHandling.UserItem, + executeCommand: (UpdateStateCommand) -> Unit +) { + // TODO: implement this without dependency on MainActivity + val activity = LocalContext.current as MainActivity + + ListCardCommon.Card( + Modifier + .animateItemPlacement() + .padding(horizontal = 8.dp) + .clickable( + onClick = { + when (user.type) { + UserType.Child -> { + if (!user.viewingNeedsAuthentication || activity.getActivityViewModel().isParentOrChildAuthenticated(user.id)) { + executeCommand(UpdateStateCommand.Overview.ManageChild(user.id)) + } else { + activity.showAuthenticationScreen() + } + } + UserType.Parent -> executeCommand(UpdateStateCommand.Overview.ManageParent(user.id)) + } + } + ) + ) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.AccountCircle, + label = stringResource(R.string.overview_user_item_name), + value = user.name, + style = MaterialTheme.typography.h6 + ) + + ListCardCommon.TextWithIcon( + icon = when (user.type) { + UserType.Child -> Icons.Default.Security + UserType.Parent -> Icons.Default.Settings + }, + label = stringResource(R.string.overview_user_item_role), + value = when (user.type) { + UserType.Child -> stringResource(R.string.overview_user_item_role_child) + UserType.Parent -> stringResource(R.string.overview_user_item_role_parent) + } + ) + + if (user.areLimitsTemporarilyDisabled) { + ListCardCommon.TextWithIcon( + icon = Icons.Default.AlarmOff, + label = stringResource(R.string.overview_user_item_temporarily_disabled), + value = stringResource(R.string.overview_user_item_temporarily_disabled) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/setup/SetupDevicePermissionsFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/SetupDevicePermissionsFragment.kt index 7077c12..09bd892 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/SetupDevicePermissionsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/SetupDevicePermissionsFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,15 +20,14 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import androidx.navigation.Navigation -import io.timelimit.android.R import io.timelimit.android.async.Threads import io.timelimit.android.databinding.FragmentSetupDevicePermissionsBinding -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.integration.platform.SystemPermission import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.ui.manage.device.manage.permission.PermissionInfoHelpDialog +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class SetupDevicePermissionsFragment : Fragment() { private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } @@ -45,8 +44,6 @@ class SetupDevicePermissionsFragment : Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val navigation = Navigation.findNavController(container!!) - binding = FragmentSetupDevicePermissionsBinding.inflate(inflater, container, false) binding.handlers = object: SetupDevicePermissionsHandlers { @@ -71,11 +68,7 @@ class SetupDevicePermissionsFragment : Fragment() { } override fun gotoNextStep() { - navigation.safeNavigate( - SetupDevicePermissionsFragmentDirections - .actionSetupDevicePermissionsFragmentToSetupLocalModeFragment(), - R.id.setupDevicePermissionsFragment - ) + requireActivity().execute(UpdateStateCommand.Setup.LocalMode) } override fun helpUsageStatsAccess() { diff --git a/app/src/main/java/io/timelimit/android/ui/setup/SetupHelpInfoFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/SetupHelpInfoFragment.kt index 35cf203..1dd2093 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/SetupHelpInfoFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/SetupHelpInfoFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,11 +21,11 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.databinding.SetupHelpInfoFragmentBinding -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.ui.help.HelpDialogFragment +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class SetupHelpInfoFragment: Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -41,10 +41,7 @@ class SetupHelpInfoFragment: Fragment() { } binding.nextButton.setOnClickListener { - Navigation.findNavController(view!!).safeNavigate( - SetupHelpInfoFragmentDirections.actionSetupHelpInfoFragmentToSetupSelectModeFragment(), - R.id.setupHelpInfoFragment - ) + requireActivity().execute(UpdateStateCommand.Setup.SelectMode) } return binding.root diff --git a/app/src/main/java/io/timelimit/android/ui/setup/SetupLocalModeFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/SetupLocalModeFragment.kt index 385e00a..95e07a1 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/SetupLocalModeFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/SetupLocalModeFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,7 +28,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.* -import androidx.navigation.Navigation import io.timelimit.android.BuildConfig import io.timelimit.android.R import io.timelimit.android.coroutines.runAsync diff --git a/app/src/main/java/io/timelimit/android/ui/setup/SetupSelectModeFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/SetupSelectModeFragment.kt index 87baa3f..ad11b99 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/SetupSelectModeFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/SetupSelectModeFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,16 +28,15 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.Fragment -import androidx.navigation.NavController -import androidx.navigation.Navigation import io.timelimit.android.BuildConfig import io.timelimit.android.R import io.timelimit.android.async.Threads import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.runAsync import io.timelimit.android.databinding.FragmentSetupSelectModeBinding -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute import io.timelimit.android.ui.setup.parentmode.SetupParentmodeDialogFragment import io.timelimit.android.ui.setup.privacy.PrivacyInfoDialogFragment @@ -49,7 +48,6 @@ class SetupSelectModeFragment : Fragment() { private const val REQUEST_SETUP_PARENT_MODE = 3 } - private lateinit var navigation: NavController private lateinit var binding: FragmentSetupSelectModeBinding override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -61,13 +59,8 @@ class SetupSelectModeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - navigation = Navigation.findNavController(view) - binding.btnLocalMode.setOnClickListener { - navigation.safeNavigate( - SetupSelectModeFragmentDirections.actionSetupSelectModeFragmentToSetupDevicePermissionsFragment(), - R.id.setupSelectModeFragment - ) + requireActivity().execute(UpdateStateCommand.Setup.DevicePermissions) } binding.btnParentMode.setOnClickListener { @@ -131,15 +124,9 @@ class SetupSelectModeFragment : Fragment() { if (resultCode == Activity.RESULT_OK) { if (requestCode == REQ_SETUP_CONNECTED_CHILD) { - navigation.safeNavigate( - SetupSelectModeFragmentDirections.actionSetupSelectModeFragmentToSetupRemoteChildFragment(), - R.id.setupSelectModeFragment - ) + requireActivity().execute(UpdateStateCommand.Setup.RemoteChild) } else if (requestCode == REQ_SETUP_CONNECTED_PARENT) { - navigation.safeNavigate( - SetupSelectModeFragmentDirections.actionSetupSelectModeFragmentToSetupParentModeFragment(), - R.id.setupSelectModeFragment - ) + requireActivity().execute(UpdateStateCommand.Setup.ParentMode) } } } diff --git a/app/src/main/java/io/timelimit/android/ui/setup/SetupTermsFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/SetupTermsFragment.kt index 4d27533..505d420 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/SetupTermsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/SetupTermsFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,11 +23,11 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.Observer -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.databinding.FragmentSetupTermsBinding -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute import io.timelimit.android.ui.obsolete.ObsoleteDialogFragment import io.timelimit.android.ui.setup.customserver.SelectCustomServerDialogFragment @@ -66,9 +66,6 @@ class SetupTermsFragment : Fragment() { } private fun acceptTerms() { - Navigation.findNavController(view!!).safeNavigate( - SetupTermsFragmentDirections.actionSetupTermsFragmentToSetupHelpInfoFragment(), - R.id.setupTermsFragment - ) + requireActivity().execute(UpdateStateCommand.Setup.Help) } } diff --git a/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceFragment.kt index 721fcda..51fad17 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -33,21 +33,20 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.observe -import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.coroutines.runAsync import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.model.AppRecommendation import io.timelimit.android.data.model.UserType import io.timelimit.android.databinding.FragmentSetupDeviceBinding -import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.livedata.* import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.FragmentWithCustomTitle import io.timelimit.android.ui.manage.device.manage.advanced.ManageDeviceBackgroundSync +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute import io.timelimit.android.ui.mustread.MustReadFragment -import io.timelimit.android.ui.overview.main.MainFragmentDirections import io.timelimit.android.ui.setup.SetupNetworkTimeVerification import io.timelimit.android.ui.update.UpdateConsentCard import io.timelimit.android.ui.view.NotifyPermissionCard @@ -121,7 +120,6 @@ class SetupDeviceFragment : Fragment(), FragmentWithCustomTitle { val binding = FragmentSetupDeviceBinding.inflate(inflater, container, false) val logic = DefaultAppLogic.with(requireContext()) val activity = activity as ActivityViewModelHolder - val navigation = Navigation.findNavController(container!!) binding.needsParent.authBtn.setOnClickListener { activity.showAuthenticationScreen() @@ -147,13 +145,7 @@ class SetupDeviceFragment : Fragment(), FragmentWithCustomTitle { val ownDeviceId = logic.deviceId.waitForNullableValue()!! - navigation.popBackStack() - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToManageDeviceFragment( - ownDeviceId - ), - R.id.overviewFragment - ) + requireActivity().execute(UpdateStateCommand.ManageDevice.EnterFromDeviceSetup(ownDeviceId)) } } SetupDeviceModelStatus.Working -> { /* nothing to do */ } diff --git a/app/src/main/java/io/timelimit/android/ui/user/create/AddUserFragment.kt b/app/src/main/java/io/timelimit/android/ui/user/create/AddUserFragment.kt index cf3ae8d..2fadc31 100644 --- a/app/src/main/java/io/timelimit/android/ui/user/create/AddUserFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/user/create/AddUserFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,7 +24,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders -import androidx.navigation.Navigation import com.google.android.material.snackbar.Snackbar import io.timelimit.android.R import io.timelimit.android.data.model.UserType @@ -33,6 +32,8 @@ import io.timelimit.android.livedata.* import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.model.UpdateStateCommand +import io.timelimit.android.ui.model.execute class AddUserFragment : Fragment(), FragmentWithCustomTitle { companion object { @@ -47,7 +48,6 @@ class AddUserFragment : Fragment(), FragmentWithCustomTitle { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val binding = FragmentAddUserBinding.inflate(inflater, container, false) - val navigation = Navigation.findNavController(container!!) // user type @@ -121,7 +121,8 @@ class AddUserFragment : Fragment(), FragmentWithCustomTitle { } AddUserModelStatus.Done -> { Snackbar.make(binding.root, R.string.add_user_confirmation_done, Snackbar.LENGTH_SHORT).show() - navigation.popBackStack() + + requireActivity().execute(UpdateStateCommand.AddUser.Leave) binding.flipper.displayedChild = PAGE_WAIT } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index eeb9c0e..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/app/src/main/res/layout/fragment_overview.xml b/app/src/main/res/layout/fragment_overview.xml deleted file mode 100644 index 237fdf7..0000000 --- a/app/src/main/res/layout/fragment_overview.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/fragment_overview_device_item.xml b/app/src/main/res/layout/fragment_overview_device_item.xml deleted file mode 100644 index eb8d21d..0000000 --- a/app/src/main/res/layout/fragment_overview_device_item.xml +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_overview_finish_setup.xml b/app/src/main/res/layout/fragment_overview_finish_setup.xml deleted file mode 100644 index 0d813f9..0000000 --- a/app/src/main/res/layout/fragment_overview_finish_setup.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - -