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..040d56a1 100644 --- a/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt +++ b/app/src/main/java/at/bitfire/icsdroid/model/AddSubscriptionModel.kt @@ -1,7 +1,9 @@ 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 androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -15,6 +17,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 @@ -22,18 +27,35 @@ import kotlinx.coroutines.launch 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 success: Boolean = false, val errorMessage: String? = null, val isCreating: Boolean = false, val showNextButton: Boolean = false, @@ -129,10 +151,10 @@ class AddSubscriptionModel @Inject constructor( // sync the subscription to reflect the changes in the calendar provider SyncWorker.run(context) } - uiState = uiState.copy(success = true) + toastAsync(context, message = context.getString(R.string.add_calendar_created)) } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't create calendar", e) - uiState = uiState.copy(errorMessage = e.localizedMessage ?: e.message) + toastAsync(context, message = e.localizedMessage ?: e.message) } finally { uiState = uiState.copy(isCreating = false) } @@ -217,4 +239,25 @@ class AddSubscriptionModel @Inject constructor( } return uri } + + 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) + + checkUrlIntroductionPage() + } } \ No newline at end of file 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/model/SubscriptionsModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt index 1c65595d..670b7b08 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 @@ -184,10 +182,7 @@ class SubscriptionsModel @Inject constructor( fun onBackupExportRequested(uri: Uri) { viewModelScope.launch(Dispatchers.IO) { - val toast = toastAsync( - messageResId = R.string.backup_exporting, - duration = Toast.LENGTH_LONG - ) + val toast = toastAsync(context, context.getString(R.string.backup_exporting)) val subscriptions = subscriptions.value Log.i(TAG, "Exporting ${subscriptions.size} subscriptions...") @@ -205,25 +200,21 @@ class SubscriptionsModel @Inject constructor( } toastAsync( - messageResId = R.string.backup_exported, - cancelToast = toast + context, + message = context.getString(R.string.backup_exported), + cancelToast = toast, + duration = Toast.LENGTH_SHORT ) } catch (e: IOException) { Log.e(TAG, "Could not write export file.", e) - toastAsync( - messageResId = R.string.backup_export_error_io, - duration = Toast.LENGTH_LONG - ) + toastAsync(context, context.getString(R.string.backup_export_error_io)) } } } fun onBackupImportRequested(uri: Uri) { viewModelScope.launch(Dispatchers.IO) { - val toast = toastAsync( - messageResId = R.string.backup_importing, - duration = Toast.LENGTH_LONG - ) + val toast = toastAsync(context, context.getString(R.string.backup_importing)) try { val jsonString = context.contentResolver.openFileDescriptor(uri, "r")?.use { fd -> @@ -233,9 +224,9 @@ class SubscriptionsModel @Inject constructor( } if (jsonString == null) { toastAsync( - messageResId = R.string.backup_import_error_io, - cancelToast = toast, - duration = Toast.LENGTH_LONG + context, + message = context.getString(R.string.backup_import_error_io), + cancelToast = toast ) return@launch } @@ -267,48 +258,33 @@ class SubscriptionsModel @Inject constructor( SyncWorker.run(context) toastAsync( - message = { - resources.getQuantityString(R.plurals.backup_imported, newSubscriptions.size, newSubscriptions.size) - }, - cancelToast = toast + context, + message = context.resources.getQuantityString(R.plurals.backup_imported, newSubscriptions.size, newSubscriptions.size), + cancelToast = toast, + duration = Toast.LENGTH_SHORT ) } catch (e: JSONException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( - messageResId = R.string.backup_import_error_json, - cancelToast = toast, - duration = Toast.LENGTH_LONG + context, + message = context.getString(R.string.backup_import_error_json), + cancelToast = toast ) } catch (e: SecurityException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( - messageResId = R.string.backup_import_error_security, - cancelToast = toast, - duration = Toast.LENGTH_LONG + context, + message = context.getString(R.string.backup_import_error_security), + cancelToast = toast ) } catch (e: IOException) { Log.e(TAG, "Could not load JSON: $e") toastAsync( - messageResId = R.string.backup_import_error_io, - cancelToast = toast, - duration = Toast.LENGTH_LONG + context, + message = context.getString(R.string.backup_import_error_io), + cancelToast = toast ) } } } - - 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..e352c916 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt @@ -0,0 +1,22 @@ +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, + @ToastDuration duration: Int = Toast.LENGTH_LONG, +): Toast? = withContext(Dispatchers.Main) { + cancelToast?.cancel() + + Toast.makeText(context, message, duration).also { it.show() } +} 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..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 @@ -4,22 +4,17 @@ 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 -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState @@ -42,7 +37,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 @@ -60,72 +54,15 @@ fun AddSubscriptionScreen( title: String?, color: Int?, url: String?, - model: AddSubscriptionModel = hiltViewModel(), + model: AddSubscriptionModel = hiltViewModel { vmf: AddSubscriptionModel.Factory -> vmf.create(title, color, url) }, 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()) - - // 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 - ) - } -} - -@Composable -fun AddSubscriptionScreen( - model: AddSubscriptionModel, - onPickFileRequested: () -> Unit, - finish: () -> Unit -) { - val scope = rememberCoroutineScope() - val pagerState = rememberPagerState { 2 } + ) { uri: Uri? -> model.onFilePicked(uri) } // Receive updates for the URL introduction page with(model.subscriptionSettingsUseCase.uiState) { @@ -179,6 +116,7 @@ fun AddSubscriptionScreen( onUrlChange = { setUrl(it) setFileName(null) + model.checkUrlIntroductionPage() }, fileName = uiState.fileName, urlError = uiState.urlError, @@ -202,7 +140,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 +163,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