diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt index 9e98aef..1b7ccae 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt @@ -109,8 +109,7 @@ class AccountActivity : AppCompatActivity() { viewModel.useSso = false preferences.edit { putBoolean(SETTINGS_USE_SSO, false) } } - }, - onLogout = { viewModel.logout() } + } ) } diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt index c6e25d0..7c7f7e2 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt @@ -14,6 +14,7 @@ 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.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -28,8 +29,7 @@ fun AccountScreen( viewModel: AccountViewModel, onBack: () -> Unit, onConnect: () -> Unit, - onSsoClick: (Boolean) -> Unit, - onLogout: () -> Unit + onSsoClick: (Boolean) -> Unit ) { AccountScreenContent( isLoggedIn = viewModel.isLoggedIn, @@ -51,7 +51,57 @@ fun AccountScreen( onBack = onBack, onConnect = onConnect, onSsoClick = onSsoClick, - onLogout = onLogout + onLogout = { viewModel.requestLogout() } + ) + + viewModel.logoutImpact?.let { impact -> + LogoutConfirmationDialog( + impact = impact, + onDismiss = { viewModel.cancelLogout() }, + onConfirm = { viewModel.confirmLogout() } + ) + } +} + +@Composable +fun LogoutConfirmationDialog( + impact: AccountViewModel.LogoutImpact, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.logout_confirm_title)) }, + text = { + Column { + Text( + if (impact.projects > 0) { + pluralStringResource( + R.plurals.logout_confirm_projects, impact.projects, impact.projects + ) + } else { + stringResource(R.string.logout_confirm_no_projects) + } + ) + if (impact.unsyncedBills > 0) { + Spacer(Modifier.height(12.dp)) + Text( + text = pluralStringResource( + R.plurals.warning_unsynced_bills, + impact.unsyncedBills, + impact.unsyncedBills + ), + fontWeight = FontWeight.Bold + ) + } + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_logout)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.simple_cancel)) } + } ) } diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt index 3962f97..d5058dc 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt @@ -6,6 +6,7 @@ import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.annotation.VisibleForTesting import androidx.core.content.edit import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope @@ -13,11 +14,17 @@ import androidx.preference.PreferenceManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.SecureStorage class AccountViewModel(application: Application) : AndroidViewModel(application) { + + private companion object { + const val TAG = "AccountViewModel" + } + private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) @@ -43,6 +50,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) var showWebView by mutableStateOf(false) var isLoggedIn by mutableStateOf(false) + + /** Non-null while the logout confirmation is up, carrying what it would cost. */ + var logoutImpact by mutableStateOf(null) private set var isValidatingLogin by mutableStateOf(false) @@ -90,25 +100,60 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) } } - private fun forgetProjectsTheAccountProvided() { - try { - val db = CowspentSQLiteOpenHelper.getInstance(getApplication()) - val offered = db.accountProjects - val cospendPath = "/index.php/apps/cospend" - for (project in db.projects) { - val matches = offered.any { - it.remoteId == project.remoteId && - project.serverUrl?.replace("/+$".toRegex(), "") == - it.ncUrl.replace("/+$".toRegex(), "") + cospendPath + private fun projectsTheAccountProvided(db: CowspentSQLiteOpenHelper): List { + val offered = db.accountProjects + val cospendPath = "/index.php/apps/cospend" + return db.projects.filter { project -> + offered.any { + it.remoteId == project.remoteId && + project.serverUrl?.replace("/+$".toRegex(), "") == + it.ncUrl.replace("/+$".toRegex(), "") + cospendPath + } + } + } + data class LogoutImpact(val projects: Int, val unsyncedBills: Int) + + private fun measureLogoutImpact(): LogoutImpact { + val db = CowspentSQLiteOpenHelper.getInstance(getApplication()) + val projects = projectsTheAccountProvided(db) + val unsynced = projects.sumOf { db.countUnsyncedBills(it.id) } + return LogoutImpact(projects.size, unsynced) + } + + fun requestLogout() { + viewModelScope.launch { + logoutImpact = withContext(Dispatchers.IO) { + try { + measureLogoutImpact() + } catch (e: Exception) { + // Never let a failed count block signing out - just ask without the detail. + Log.e(TAG, "Could not measure what logging out would remove", e) + LogoutImpact(0, 0) } - if (matches) db.deleteProject(project.id) } - db.clearAccountProjects() - } catch (e: Exception) { - Log.e("AccountViewModel", "Could not remove the account's projects on logout", e) } } + fun cancelLogout() { + logoutImpact = null + } + + fun confirmLogout() { + logoutImpact = null + logout() + } + + private fun forgetProjectsTheAccountProvided() { + try { + val db = CowspentSQLiteOpenHelper.getInstance(getApplication()) + projectsTheAccountProvided(db).forEach { db.deleteProject(it.id) } + db.clearAccountProjects() + } catch (e: Exception) { + Log.e(TAG, "Could not remove the account's projects on logout", e) + } + } + + @VisibleForTesting fun logout() { viewModelScope.launch { withContext(Dispatchers.IO) { forgetProjectsTheAccountProvided() } diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt index 0dbefee..a0d2379 100644 --- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt @@ -408,11 +408,26 @@ class BillsListViewActivity : private fun onRemoveProjectClick(projectId: Long) { if (projectId == 0L) return lifecycleScope.launch { - val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch - + val (proj, unsyncedBills) = withContext(Dispatchers.IO) { + val p = db.getProject(projectId) ?: return@withContext null + p to db.countUnsyncedBills(projectId) + } ?: return@launch + + val message = buildString { + if (!proj.isLocal) append(getString(R.string.dialog_confirm_remove_project_msg)) + if (unsyncedBills > 0) { + if (isNotEmpty()) append("\n\n") + append( + resources.getQuantityString( + R.plurals.warning_unsynced_bills, unsyncedBills, unsyncedBills + ) + ) + } + } + viewModel.showDialog( title = getString(R.string.title_confirm), - message = if (!proj.isLocal) getString(R.string.dialog_confirm_remove_project_msg) else null, + message = message.ifEmpty { null }, positiveText = getString(R.string.simple_yes), onConfirm = { lifecycleScope.launch { diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt index b3ffa6d..69c302a 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.ContentValues import android.content.Context import android.database.Cursor +import android.database.DatabaseUtils import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.text.TextUtils @@ -553,6 +554,14 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : fun getBillsOfProject(projId: Long): List { return getBillsCustom("$key_projectid = ?", arrayOf(projId.toString()), "$key_timestamp ASC") } + + @WorkerThread + fun countUnsyncedBills(projId: Long): Int = DatabaseUtils.queryNumEntries( + readableDatabase, + table_bills, + "$key_projectid = ? AND $key_state != ?", + arrayOf(projId.toString(), DBBill.STATE_OK.toString()) + ).toInt() fun getBillsOfProjectWithState(projId: Long, state: Int): List { return getBillsCustom( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17cddf8..44e3d01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,8 @@ Settings Label missing categories Logout + Log out? + You can sign back in at any time. Connect Discard Members @@ -299,4 +301,14 @@ %d project the account offers is kept off this device. %d projects the account offers are kept off this device. + + + %d project from this account will be removed from this device. It comes back when you sign in again. + %d projects from this account will be removed from this device. They come back when you sign in again. + + + + %d bill has not reached the server yet and will be lost. + %d bills have not reached the server yet and will be lost. +