From a41982bdfda62a9e357ae64ef891c6d8dd6eabaf Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 28 Sep 2025 16:07:32 +0200 Subject: [PATCH 01/12] First iteration of `AddSubscriptionScreen` cleanup Signed-off-by: Arnau Mora --- .../icsdroid/model/AddSubscriptionModel.kt | 41 +++++++++- .../ui/screen/AddSubscriptionScreen.kt | 75 +++---------------- 2 files changed, 47 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt index 7c839a25..849baae2 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt @@ -1,8 +1,11 @@ package at.bitfire.icsdroid.model import android.content.Context +import android.content.Intent import android.net.Uri +import android.provider.OpenableColumns import android.util.Log +import android.widget.Toast import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -19,6 +22,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrl import java.net.URI import java.net.URISyntaxException @@ -33,7 +37,6 @@ class AddSubscriptionModel @Inject constructor( ) : ViewModel() { data class UiState( - val success: Boolean = false, val errorMessage: String? = null, val isCreating: Boolean = false, val showNextButton: Boolean = false, @@ -129,10 +132,14 @@ class AddSubscriptionModel @Inject constructor( // sync the subscription to reflect the changes in the calendar provider SyncWorker.run(context) } - uiState = uiState.copy(success = true) + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.add_calendar_created, Toast.LENGTH_LONG).show() + } } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't create calendar", e) - uiState = uiState.copy(errorMessage = e.localizedMessage ?: e.message) + withContext(Dispatchers.Main) { + Toast.makeText(context, e.localizedMessage ?: e.message, Toast.LENGTH_LONG).show() + } } finally { uiState = uiState.copy(isCreating = false) } @@ -217,4 +224,32 @@ class AddSubscriptionModel @Inject constructor( } return uri } + + fun initialize(title: String?, color: Int?, url: String?,) { + if (subscriptionSettingsUseCase.uiState.isInitialized()) return + subscriptionSettingsUseCase.setInitialValues(title, color, url) + + if (url != null) { + checkUrlIntroductionPage() + } + } + + fun onFilePicked(uri: Uri?) { + if (uri == null) return + + // keep the picked file accessible after the first sync and reboots + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + subscriptionSettingsUseCase.setUrl(uri.toString()) + + // Get file name + val displayName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val name = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.getString(name) + } + subscriptionSettingsUseCase.setFileName(displayName) + } } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt index ae8debf3..b80e5292 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt @@ -4,18 +4,14 @@ package at.bitfire.icsdroid.ui.screen -import android.content.Intent import android.net.Uri -import android.provider.OpenableColumns import android.util.Log -import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -42,7 +38,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -63,69 +58,16 @@ fun AddSubscriptionScreen( model: AddSubscriptionModel = hiltViewModel(), onBackRequested: () -> Unit ) { - val context = LocalContext.current - val uiState = model.uiState - - LaunchedEffect(uiState) { - if (uiState.success) { - // on success, show notification and close activity - Toast.makeText(context, context.getString(R.string.add_calendar_created), Toast.LENGTH_LONG).show() - onBackRequested() - } - uiState.errorMessage?.let { - // on error, show error message - Toast.makeText(context, it, Toast.LENGTH_LONG).show() - } - } - - LaunchedEffect(title, color, url) { - if (model.subscriptionSettingsUseCase.uiState.isInitialized()) - return@LaunchedEffect - model.subscriptionSettingsUseCase.setInitialValues(title, color, url) - - if (url != null) { - model.checkUrlIntroductionPage() - } - } + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState { 2 } val pickFile = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() - ) { uri: Uri? -> - if (uri != null) { - // keep the picked file accessible after the first sync and reboots - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - model.subscriptionSettingsUseCase.setUrl(uri.toString()) + ) { uri: Uri? -> model.onFilePicked(uri) } - // Get file name - val displayName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (!cursor.moveToFirst()) return@use null - val name = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - cursor.getString(name) - } - model.subscriptionSettingsUseCase.setFileName(displayName) - } - } - - Box(modifier = Modifier.imePadding()) { - AddSubscriptionScreen( - model = model, - onPickFileRequested = { pickFile.launch(arrayOf("text/calendar")) }, - finish = onBackRequested - ) + LaunchedEffect(title, color, url) { + model.initialize(title, color, url) } -} - -@Composable -fun AddSubscriptionScreen( - model: AddSubscriptionModel, - onPickFileRequested: () -> Unit, - finish: () -> Unit -) { - val scope = rememberCoroutineScope() - val pagerState = rememberPagerState { 2 } // Receive updates for the URL introduction page with(model.subscriptionSettingsUseCase.uiState) { @@ -202,7 +144,7 @@ fun AddSubscriptionScreen( isCreating = model.uiState.isCreating, validationResult = validationResult, onResetResult = model::resetValidationResult, - onPickFileRequested = onPickFileRequested, + onPickFileRequested = { pickFile.launch(arrayOf("text/calendar")) }, onNextRequested = { page: Int -> when (page) { // First page (Enter Url) @@ -225,13 +167,13 @@ fun AddSubscriptionScreen( } // Second page (details and confirm) 1 -> { - model.createSubscription() + model.createSubscription().invokeOnCompletion { onBackRequested() } } } }, onNavigationClicked = { // If first page, close activity - if (pagerState.currentPage <= 0) finish() + if (pagerState.currentPage <= 0) onBackRequested() // otherwise, go back a page else scope.launch { // Needed for non-first-time validations to trigger following validation result updates @@ -296,6 +238,7 @@ fun AddSubscriptionScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues) + .imePadding() ) { page -> when (page) { 0 -> EnterUrlComposable( From d8fb0dd21b616dbe56a1de70ed91012e01149cb8 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 28 Sep 2025 16:17:24 +0200 Subject: [PATCH 02/12] Update app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt index 849baae2..6ec29245 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt @@ -138,7 +138,7 @@ class AddSubscriptionModel @Inject constructor( } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't create calendar", e) withContext(Dispatchers.Main) { - Toast.makeText(context, e.localizedMessage ?: e.message, Toast.LENGTH_LONG).show() + Toast.makeText(context, e.localizedMessage ?: e.message, Toast.LENGTH_LONG).show() } } finally { uiState = uiState.copy(isCreating = false) From 7702ddd412d4d649463247dd8491315a52260f59 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 28 Sep 2025 16:20:57 +0200 Subject: [PATCH 03/12] Fix url integrity checks Signed-off-by: Arnau Mora --- .../java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt index b80e5292..1996586d 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt @@ -121,6 +121,7 @@ fun AddSubscriptionScreen( onUrlChange = { setUrl(it) setFileName(null) + model.checkUrlIntroductionPage() }, fileName = uiState.fileName, urlError = uiState.urlError, From 85fbcb24f4fb0ccbe1c265a01c1a7994b5ecd1a3 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 28 Sep 2025 16:22:47 +0200 Subject: [PATCH 04/12] Remove unnecessary padding Signed-off-by: Arnau Mora --- .../java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt index 1996586d..31f08a62 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState @@ -239,7 +238,6 @@ fun AddSubscriptionScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues) - .imePadding() ) { page -> when (page) { 0 -> EnterUrlComposable( From 6ebf7e97e5099720e403f8d6053c5fabe042b7d3 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 28 Sep 2025 16:40:56 +0200 Subject: [PATCH 05/12] Use VM factory Signed-off-by: Arnau Mora --- .../icsdroid/model/AddSubscriptionModel.kt | 40 ++++++++++++------- .../model/SubscriptionSettingsUseCase.kt | 28 +++---------- .../ui/screen/AddSubscriptionScreen.kt | 8 ++-- 3 files changed, 35 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt index 6ec29245..140035b0 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt @@ -18,6 +18,9 @@ import at.bitfire.icsdroid.db.AppDatabase import at.bitfire.icsdroid.db.entity.Credential import at.bitfire.icsdroid.db.entity.Subscription import at.bitfire.icsdroid.ui.ResourceInfo +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -26,16 +29,34 @@ import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrl import java.net.URI import java.net.URISyntaxException -import javax.inject.Inject -@HiltViewModel -class AddSubscriptionModel @Inject constructor( +@HiltViewModel(assistedFactory = AddSubscriptionModel.Factory::class) +class AddSubscriptionModel @AssistedInject constructor( + @Assisted("title") initialTitle: String?, + @Assisted("color") initialColor: Int?, + @Assisted("url") initialUrl: String?, @param:ApplicationContext private val context: Context, private val db: AppDatabase, - val validator: Validator, - val subscriptionSettingsUseCase: SubscriptionSettingsUseCase + val validator: Validator ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + @Assisted("title") title: String? = null, + @Assisted("color") color: Int? = null, + @Assisted("url") url: String? = null + ): AddSubscriptionModel + } + + val subscriptionSettingsUseCase: SubscriptionSettingsUseCase = SubscriptionSettingsUseCase( + SubscriptionSettingsUseCase.UiState( + title = initialTitle, + color = initialColor, + url = initialUrl + ) + ) + data class UiState( val errorMessage: String? = null, val isCreating: Boolean = false, @@ -225,15 +246,6 @@ class AddSubscriptionModel @Inject constructor( return uri } - fun initialize(title: String?, color: Int?, url: String?,) { - if (subscriptionSettingsUseCase.uiState.isInitialized()) return - subscriptionSettingsUseCase.setInitialValues(title, color, url) - - if (url != null) { - checkUrlIntroductionPage() - } - } - fun onFilePicked(uri: Uri?) { if (uri == null) return diff --git a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionSettingsUseCase.kt b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionSettingsUseCase.kt index d09ac4fd..55a6cf1a 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionSettingsUseCase.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionSettingsUseCase.kt @@ -9,7 +9,11 @@ import at.bitfire.icsdroid.db.entity.Credential import at.bitfire.icsdroid.db.entity.Subscription import javax.inject.Inject -class SubscriptionSettingsUseCase @Inject constructor() { +class SubscriptionSettingsUseCase(initialUiState: UiState = UiState()) { + + @Deprecated("Do not inject constructor. Manually initialize with initial state.") + @Inject constructor(): this(UiState()) + data class UiState( val url: String? = null, val fileName: String? = null, @@ -33,11 +37,9 @@ class SubscriptionSettingsUseCase @Inject constructor() { val validUrlInput: Boolean = url?.let { url -> HttpUtils.acceptedProtocol(url.toUri()) } ?: false - - fun isInitialized() = url != null || title != null || color != null } - var uiState by mutableStateOf(UiState()) + var uiState by mutableStateOf(initialUiState) private set fun setUrl(value: String?) { @@ -98,24 +100,6 @@ class SubscriptionSettingsUseCase @Inject constructor() { ) } - /** - * Set initial values when creating a new subscription. - * - * Note that all values will be overwritten, so call this method before changing any individual - * value, or when you want to reset the form to an initial state. - */ - fun setInitialValues( - title: String?, - color: Int?, - url: String?, - ) { - uiState = UiState( - title = title, - color = color, - url = url, - ) - } - fun equalsSubscription(subscription: Subscription) = uiState.url == subscription.url.toString() && uiState.title == subscription.displayName diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt index 31f08a62..7bc29214 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt @@ -54,7 +54,9 @@ fun AddSubscriptionScreen( title: String?, color: Int?, url: String?, - model: AddSubscriptionModel = hiltViewModel(), + model: AddSubscriptionModel = hiltViewModel { + it.create(title, color, url) + }, onBackRequested: () -> Unit ) { val scope = rememberCoroutineScope() @@ -64,10 +66,6 @@ fun AddSubscriptionScreen( ActivityResultContracts.OpenDocument() ) { uri: Uri? -> model.onFilePicked(uri) } - LaunchedEffect(title, color, url) { - model.initialize(title, color, url) - } - // Receive updates for the URL introduction page with(model.subscriptionSettingsUseCase.uiState) { LaunchedEffect(url, requiresAuth, username, password) { From f56ae4eac29b993be565db579497bd7289bc730e Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 28 Sep 2025 16:43:18 +0200 Subject: [PATCH 06/12] Change naming Signed-off-by: Arnau Mora --- .../at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt index 7bc29214..0ffcb269 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/screen/AddSubscriptionScreen.kt @@ -54,9 +54,7 @@ fun AddSubscriptionScreen( title: String?, color: Int?, url: String?, - model: AddSubscriptionModel = hiltViewModel { - it.create(title, color, url) - }, + model: AddSubscriptionModel = hiltViewModel { vmf: AddSubscriptionModel.Factory -> vmf.create(title, color, url) }, onBackRequested: () -> Unit ) { val scope = rememberCoroutineScope() From c38f012d56bc51648acc674cd855348200ea6ea2 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 29 Sep 2025 12:37:15 +0200 Subject: [PATCH 07/12] Add utility function for showing toasts Signed-off-by: Arnau Mora --- .../icsdroid/model/AddSubscriptionModel.kt | 18 ++++++++----- .../icsdroid/model/SubscriptionsModel.kt | 26 +++++++------------ .../bitfire/icsdroid/model/ViewModelUtils.kt | 24 +++++++++++++++++ 3 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt diff --git a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt index 140035b0..600e28c1 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt @@ -153,14 +153,20 @@ class AddSubscriptionModel @AssistedInject constructor( // sync the subscription to reflect the changes in the calendar provider SyncWorker.run(context) } - withContext(Dispatchers.Main) { - Toast.makeText(context, R.string.add_calendar_created, Toast.LENGTH_LONG).show() - } + toastAsync( + context, + messageResId = R.string.add_calendar_created, + cancelToast = null, + duration = Toast.LENGTH_LONG + ) } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't create calendar", e) - withContext(Dispatchers.Main) { - Toast.makeText(context, e.localizedMessage ?: e.message, Toast.LENGTH_LONG).show() - } + toastAsync( + context, + message = { e.localizedMessage ?: e.message }, + cancelToast = null, + duration = Toast.LENGTH_LONG + ) } finally { uiState = uiState.copy(isCreating = false) } diff --git a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt index 1c65595d..864d49a5 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt @@ -13,7 +13,6 @@ import android.os.Build import android.os.PowerManager import android.util.Log import android.widget.Toast -import androidx.annotation.StringRes import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -39,7 +38,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONException import java.io.FileInputStream @@ -185,6 +183,7 @@ class SubscriptionsModel @Inject constructor( fun onBackupExportRequested(uri: Uri) { viewModelScope.launch(Dispatchers.IO) { val toast = toastAsync( + context, messageResId = R.string.backup_exporting, duration = Toast.LENGTH_LONG ) @@ -205,12 +204,14 @@ class SubscriptionsModel @Inject constructor( } toastAsync( + context, messageResId = R.string.backup_exported, cancelToast = toast ) } catch (e: IOException) { Log.e(TAG, "Could not write export file.", e) toastAsync( + context, messageResId = R.string.backup_export_error_io, duration = Toast.LENGTH_LONG ) @@ -221,6 +222,7 @@ class SubscriptionsModel @Inject constructor( fun onBackupImportRequested(uri: Uri) { viewModelScope.launch(Dispatchers.IO) { val toast = toastAsync( + context, messageResId = R.string.backup_importing, duration = Toast.LENGTH_LONG ) @@ -233,6 +235,7 @@ class SubscriptionsModel @Inject constructor( } if (jsonString == null) { toastAsync( + context, messageResId = R.string.backup_import_error_io, cancelToast = toast, duration = Toast.LENGTH_LONG @@ -267,6 +270,7 @@ class SubscriptionsModel @Inject constructor( SyncWorker.run(context) toastAsync( + context, message = { resources.getQuantityString(R.plurals.backup_imported, newSubscriptions.size, newSubscriptions.size) }, @@ -275,6 +279,7 @@ class SubscriptionsModel @Inject constructor( } catch (e: JSONException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( + context, messageResId = R.string.backup_import_error_json, cancelToast = toast, duration = Toast.LENGTH_LONG @@ -282,6 +287,7 @@ class SubscriptionsModel @Inject constructor( } catch (e: SecurityException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( + context, messageResId = R.string.backup_import_error_security, cancelToast = toast, duration = Toast.LENGTH_LONG @@ -289,6 +295,7 @@ class SubscriptionsModel @Inject constructor( } catch (e: IOException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( + context, messageResId = R.string.backup_import_error_io, cancelToast = toast, duration = Toast.LENGTH_LONG @@ -296,19 +303,4 @@ class SubscriptionsModel @Inject constructor( } } } - - private suspend fun toastAsync( - message: (Context.() -> String)? = null, - @StringRes messageResId: Int? = null, - cancelToast: Toast? = null, - duration: Int = Toast.LENGTH_SHORT - ): Toast? = withContext(Dispatchers.Main) { - cancelToast?.cancel() - - when { - message != null -> Toast.makeText(context, message(context), duration) - messageResId != null -> Toast.makeText(context, messageResId, duration) - else -> return@withContext null - }.also { it.show() } - } } diff --git a/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt new file mode 100644 index 00000000..e9a2b149 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt @@ -0,0 +1,24 @@ +package at.bitfire.icsdroid.model + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +suspend fun toastAsync( + context: Context, + message: Context.() -> String? = { null }, + @StringRes messageResId: Int? = null, + cancelToast: Toast? = null, + duration: Int = Toast.LENGTH_SHORT +): Toast? = withContext(Dispatchers.Main) { + cancelToast?.cancel() + + val msg = message(context) + when { + msg != null -> Toast.makeText(context, msg, duration) + messageResId != null -> Toast.makeText(context, messageResId, duration) + else -> return@withContext null + }.also { it.show() } +} From 97762ff54789ba5d1d6d83fd187efdbce95868d2 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 29 Sep 2025 12:37:27 +0200 Subject: [PATCH 08/12] Fix next button not showing after picking files Signed-off-by: Arnau Mora --- .../java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt index 600e28c1..953ab53a 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt @@ -25,7 +25,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrl import java.net.URI import java.net.URISyntaxException @@ -269,5 +268,7 @@ class AddSubscriptionModel @AssistedInject constructor( cursor.getString(name) } subscriptionSettingsUseCase.setFileName(displayName) + + checkUrlIntroductionPage() } } \ No newline at end of file From 62f8860bfbc4edaabe054ee5d99a6601cdc93262 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 29 Sep 2025 12:40:11 +0200 Subject: [PATCH 09/12] Change default duration from SHORT to LONG Signed-off-by: Arnau Mora --- .../icsdroid/model/AddSubscriptionModel.kt | 9 ++----- .../icsdroid/model/SubscriptionsModel.kt | 27 ++++++++----------- .../bitfire/icsdroid/model/ViewModelUtils.kt | 2 +- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt index 953ab53a..37fcace6 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.net.Uri import android.provider.OpenableColumns import android.util.Log -import android.widget.Toast import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -154,17 +153,13 @@ class AddSubscriptionModel @AssistedInject constructor( } toastAsync( context, - messageResId = R.string.add_calendar_created, - cancelToast = null, - duration = Toast.LENGTH_LONG + messageResId = R.string.add_calendar_created ) } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't create calendar", e) toastAsync( context, - message = { e.localizedMessage ?: e.message }, - cancelToast = null, - duration = Toast.LENGTH_LONG + message = { e.localizedMessage ?: e.message } ) } finally { uiState = uiState.copy(isCreating = false) diff --git a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt index 864d49a5..3fce58f6 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt @@ -184,8 +184,7 @@ class SubscriptionsModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { val toast = toastAsync( context, - messageResId = R.string.backup_exporting, - duration = Toast.LENGTH_LONG + messageResId = R.string.backup_exporting ) val subscriptions = subscriptions.value @@ -206,14 +205,14 @@ class SubscriptionsModel @Inject constructor( toastAsync( context, messageResId = R.string.backup_exported, - cancelToast = toast + cancelToast = toast, + duration = Toast.LENGTH_SHORT ) } catch (e: IOException) { Log.e(TAG, "Could not write export file.", e) toastAsync( context, - messageResId = R.string.backup_export_error_io, - duration = Toast.LENGTH_LONG + messageResId = R.string.backup_export_error_io ) } } @@ -223,8 +222,7 @@ class SubscriptionsModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { val toast = toastAsync( context, - messageResId = R.string.backup_importing, - duration = Toast.LENGTH_LONG + messageResId = R.string.backup_importing ) try { @@ -237,8 +235,7 @@ class SubscriptionsModel @Inject constructor( toastAsync( context, messageResId = R.string.backup_import_error_io, - cancelToast = toast, - duration = Toast.LENGTH_LONG + cancelToast = toast ) return@launch } @@ -274,31 +271,29 @@ class SubscriptionsModel @Inject constructor( message = { resources.getQuantityString(R.plurals.backup_imported, newSubscriptions.size, newSubscriptions.size) }, - cancelToast = toast + cancelToast = toast, + duration = Toast.LENGTH_SHORT ) } catch (e: JSONException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( context, messageResId = R.string.backup_import_error_json, - cancelToast = toast, - duration = Toast.LENGTH_LONG + cancelToast = toast ) } catch (e: SecurityException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( context, messageResId = R.string.backup_import_error_security, - cancelToast = toast, - duration = Toast.LENGTH_LONG + cancelToast = toast ) } catch (e: IOException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( context, messageResId = R.string.backup_import_error_io, - cancelToast = toast, - duration = Toast.LENGTH_LONG + cancelToast = toast ) } } diff --git a/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt index e9a2b149..220ba4a7 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt @@ -11,7 +11,7 @@ suspend fun toastAsync( message: Context.() -> String? = { null }, @StringRes messageResId: Int? = null, cancelToast: Toast? = null, - duration: Int = Toast.LENGTH_SHORT + duration: Int = Toast.LENGTH_LONG ): Toast? = withContext(Dispatchers.Main) { cancelToast?.cancel() From 367e8a7f4e71bddd5408d272c21273f43e40b39d Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 29 Sep 2025 14:02:16 +0200 Subject: [PATCH 10/12] Simplify toastAsync Signed-off-by: Arnau Mora --- .../icsdroid/model/AddSubscriptionModel.kt | 10 ++----- .../icsdroid/model/SubscriptionsModel.kt | 30 +++++-------------- .../bitfire/icsdroid/model/ViewModelUtils.kt | 12 ++------ 3 files changed, 13 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt index 37fcace6..a98978cc 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt @@ -151,16 +151,10 @@ class AddSubscriptionModel @AssistedInject constructor( // sync the subscription to reflect the changes in the calendar provider SyncWorker.run(context) } - toastAsync( - context, - messageResId = R.string.add_calendar_created - ) + toastAsync(context) { context.getString(R.string.add_calendar_created) } } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't create calendar", e) - toastAsync( - context, - message = { e.localizedMessage ?: e.message } - ) + toastAsync(context) { e.localizedMessage ?: e.message } } finally { uiState = uiState.copy(isCreating = false) } diff --git a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt index 3fce58f6..b22094ce 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt @@ -182,10 +182,7 @@ class SubscriptionsModel @Inject constructor( fun onBackupExportRequested(uri: Uri) { viewModelScope.launch(Dispatchers.IO) { - val toast = toastAsync( - context, - messageResId = R.string.backup_exporting - ) + val toast = toastAsync(context) { context.getString(R.string.backup_exporting) } val subscriptions = subscriptions.value Log.i(TAG, "Exporting ${subscriptions.size} subscriptions...") @@ -204,26 +201,19 @@ class SubscriptionsModel @Inject constructor( toastAsync( context, - messageResId = R.string.backup_exported, cancelToast = toast, duration = Toast.LENGTH_SHORT - ) + ) { context.getString(R.string.backup_exported) } } catch (e: IOException) { Log.e(TAG, "Could not write export file.", e) - toastAsync( - context, - messageResId = R.string.backup_export_error_io - ) + toastAsync(context) { context.getString(R.string.backup_export_error_io) } } } } fun onBackupImportRequested(uri: Uri) { viewModelScope.launch(Dispatchers.IO) { - val toast = toastAsync( - context, - messageResId = R.string.backup_importing - ) + val toast = toastAsync(context) { context.getString(R.string.backup_importing) } try { val jsonString = context.contentResolver.openFileDescriptor(uri, "r")?.use { fd -> @@ -234,9 +224,8 @@ class SubscriptionsModel @Inject constructor( if (jsonString == null) { toastAsync( context, - messageResId = R.string.backup_import_error_io, cancelToast = toast - ) + ) { context.getString(R.string.backup_import_error_io) } return@launch } @@ -278,23 +267,20 @@ class SubscriptionsModel @Inject constructor( Log.e(TAG, "Could not load JSON: $e") toastAsync( context, - messageResId = R.string.backup_import_error_json, cancelToast = toast - ) + ) { context.getString(R.string.backup_import_error_json) } } catch (e: SecurityException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( context, - messageResId = R.string.backup_import_error_security, cancelToast = toast - ) + ) { context.getString(R.string.backup_import_error_security) } } catch (e: IOException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( context, - messageResId = R.string.backup_import_error_io, cancelToast = toast - ) + ) { context.getString(R.string.backup_import_error_io) } } } } diff --git a/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt index 220ba4a7..fb36cfb8 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt @@ -2,23 +2,17 @@ package at.bitfire.icsdroid.model import android.content.Context import android.widget.Toast -import androidx.annotation.StringRes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext suspend fun toastAsync( context: Context, - message: Context.() -> String? = { null }, - @StringRes messageResId: Int? = null, cancelToast: Toast? = null, - duration: Int = Toast.LENGTH_LONG + duration: Int = Toast.LENGTH_LONG, + message: Context.() -> String? ): Toast? = withContext(Dispatchers.Main) { cancelToast?.cancel() val msg = message(context) - when { - msg != null -> Toast.makeText(context, msg, duration) - messageResId != null -> Toast.makeText(context, messageResId, duration) - else -> return@withContext null - }.also { it.show() } + Toast.makeText(context, msg, duration).also { it.show() } } From a54a7183d2a4db35586503f6dac101f46105efec Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 29 Sep 2025 14:07:44 +0200 Subject: [PATCH 11/12] Do not use lamba for message Signed-off-by: Arnau Mora --- .../icsdroid/model/AddSubscriptionModel.kt | 4 +-- .../icsdroid/model/SubscriptionsModel.kt | 25 +++++++++++-------- .../bitfire/icsdroid/model/ViewModelUtils.kt | 5 ++-- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt index a98978cc..040d56a1 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt @@ -151,10 +151,10 @@ class AddSubscriptionModel @AssistedInject constructor( // sync the subscription to reflect the changes in the calendar provider SyncWorker.run(context) } - toastAsync(context) { context.getString(R.string.add_calendar_created) } + toastAsync(context, message = context.getString(R.string.add_calendar_created)) } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't create calendar", e) - toastAsync(context) { e.localizedMessage ?: e.message } + toastAsync(context, message = e.localizedMessage ?: e.message) } finally { uiState = uiState.copy(isCreating = false) } diff --git a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt index b22094ce..670b7b08 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt @@ -182,7 +182,7 @@ class SubscriptionsModel @Inject constructor( fun onBackupExportRequested(uri: Uri) { viewModelScope.launch(Dispatchers.IO) { - val toast = toastAsync(context) { context.getString(R.string.backup_exporting) } + val toast = toastAsync(context, context.getString(R.string.backup_exporting)) val subscriptions = subscriptions.value Log.i(TAG, "Exporting ${subscriptions.size} subscriptions...") @@ -201,19 +201,20 @@ class SubscriptionsModel @Inject constructor( toastAsync( context, + message = context.getString(R.string.backup_exported), cancelToast = toast, duration = Toast.LENGTH_SHORT - ) { context.getString(R.string.backup_exported) } + ) } catch (e: IOException) { Log.e(TAG, "Could not write export file.", e) - toastAsync(context) { context.getString(R.string.backup_export_error_io) } + toastAsync(context, context.getString(R.string.backup_export_error_io)) } } } fun onBackupImportRequested(uri: Uri) { viewModelScope.launch(Dispatchers.IO) { - val toast = toastAsync(context) { context.getString(R.string.backup_importing) } + val toast = toastAsync(context, context.getString(R.string.backup_importing)) try { val jsonString = context.contentResolver.openFileDescriptor(uri, "r")?.use { fd -> @@ -224,8 +225,9 @@ class SubscriptionsModel @Inject constructor( if (jsonString == null) { toastAsync( context, + message = context.getString(R.string.backup_import_error_io), cancelToast = toast - ) { context.getString(R.string.backup_import_error_io) } + ) return@launch } @@ -257,9 +259,7 @@ class SubscriptionsModel @Inject constructor( toastAsync( context, - message = { - resources.getQuantityString(R.plurals.backup_imported, newSubscriptions.size, newSubscriptions.size) - }, + message = context.resources.getQuantityString(R.plurals.backup_imported, newSubscriptions.size, newSubscriptions.size), cancelToast = toast, duration = Toast.LENGTH_SHORT ) @@ -267,20 +267,23 @@ class SubscriptionsModel @Inject constructor( Log.e(TAG, "Could not load JSON: $e") toastAsync( context, + message = context.getString(R.string.backup_import_error_json), cancelToast = toast - ) { context.getString(R.string.backup_import_error_json) } + ) } catch (e: SecurityException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( context, + message = context.getString(R.string.backup_import_error_security), cancelToast = toast - ) { context.getString(R.string.backup_import_error_security) } + ) } catch (e: IOException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( context, + message = context.getString(R.string.backup_import_error_io), cancelToast = toast - ) { context.getString(R.string.backup_import_error_io) } + ) } } } diff --git a/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt index fb36cfb8..630d48aa 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt @@ -7,12 +7,11 @@ import kotlinx.coroutines.withContext suspend fun toastAsync( context: Context, + message: String?, cancelToast: Toast? = null, duration: Int = Toast.LENGTH_LONG, - message: Context.() -> String? ): Toast? = withContext(Dispatchers.Main) { cancelToast?.cancel() - val msg = message(context) - Toast.makeText(context, msg, duration).also { it.show() } + Toast.makeText(context, message, duration).also { it.show() } } From 0441c304c1994fd7dbad1f159778c5dc0d1f8f01 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 29 Sep 2025 14:09:33 +0200 Subject: [PATCH 12/12] Added `ToastDuration` annotation Signed-off-by: Arnau Mora --- .../main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt index 630d48aa..e352c916 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt @@ -2,14 +2,19 @@ package at.bitfire.icsdroid.model import android.content.Context import android.widget.Toast +import androidx.annotation.IntDef import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +@Retention(AnnotationRetention.SOURCE) +@IntDef(Toast.LENGTH_SHORT, Toast.LENGTH_LONG) +annotation class ToastDuration + suspend fun toastAsync( context: Context, message: String?, cancelToast: Toast? = null, - duration: Int = Toast.LENGTH_LONG, + @ToastDuration duration: Int = Toast.LENGTH_LONG, ): Toast? = withContext(Dispatchers.Main) { cancelToast?.cancel()