diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f0b4e1cd..1dfe73e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -202,7 +202,6 @@ dependencies { implementation(libs.commons.text) @Suppress("RedundantSuppression") implementation(libs.dnsjava) - implementation(libs.jaredrummler.colorpicker) implementation(libs.mikepenz.aboutLibraries) implementation(libs.nsk90.kstatemachine) implementation(libs.okhttp.base) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29129def..cae60d36 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -150,7 +150,7 @@ android:parentActivityName=".ui.account.AccountActivity" /> - @Query("SELECT COUNT(*) FROM homeset WHERE serviceId=:serviceId AND privBind") - fun hasBindableByServiceLive(serviceId: Long): LiveData + @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind") + fun getLiveBindableByService(serviceId: Long): LiveData> @Insert fun insert(homeSet: HomeSet): Long diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt index bf6eae46..edd67528 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt @@ -63,7 +63,6 @@ import at.bitfire.cert4android.CustomCertStore import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.util.TaskUtils import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPage @@ -74,6 +73,7 @@ import at.bitfire.davdroid.ui.widget.Setting import at.bitfire.davdroid.ui.widget.SettingsHeader import at.bitfire.davdroid.ui.widget.SwitchSetting import at.bitfire.davdroid.util.PermissionUtils +import at.bitfire.davdroid.util.TaskUtils import at.bitfire.ical4android.TaskProvider import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel @@ -109,9 +109,7 @@ class AppSettingsActivity: AppCompatActivity() { topBar = { TopAppBar( navigationIcon = { - IconButton(onClick = { - onSupportNavigateUp() - }) { + IconButton(onClick = { onSupportNavigateUp() }) { Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) } }, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountModel.kt index ef9ea5f1..64da6383 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountModel.kt @@ -15,10 +15,10 @@ import android.provider.ContactsContract import androidx.annotation.MainThread import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import androidx.lifecycle.map import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope @@ -26,36 +26,46 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.work.WorkInfo import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.property.caldav.NS_APPLE_ICAL +import at.bitfire.dav4jvm.property.caldav.NS_CALDAV +import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV +import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.HomeSet import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.davdroid.util.TaskUtils import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker import at.bitfire.davdroid.syncadapter.BaseSyncWorker import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker +import at.bitfire.davdroid.util.DavUtils +import at.bitfire.davdroid.util.TaskUtils +import at.bitfire.ical4android.util.DateUtils import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import net.fortuna.ical4j.model.Calendar +import java.io.StringWriter import java.util.Optional import java.util.logging.Level class AccountModel @AssistedInject constructor( - application: Application, + val context: Application, val db: AppDatabase, @Assisted val account: Account -): AndroidViewModel(application), OnAccountsUpdateListener { +): ViewModel(), OnAccountsUpdateListener { @AssistedFactory interface Factory { @@ -69,7 +79,7 @@ class AccountModel @AssistedInject constructor( /** whether the account is invalid and the AccountActivity shall be closed */ val invalid = MutableLiveData() - private val settings = AccountSettings(application, account) + private val settings = AccountSettings(context, account) private val refreshSettingsSignal = MutableLiveData(Unit) val showOnlyPersonal = refreshSettingsSignal.switchMap { object : LiveData() { @@ -85,23 +95,25 @@ class AccountModel @AssistedInject constructor( refreshSettingsSignal.postValue(Unit) } - val context = getApplication() val accountManager: AccountManager = AccountManager.get(context) val cardDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CARDDAV) - val canCreateAddressBook = cardDavSvc.switchMap { svc -> + val bindableAddressBookHomesets = cardDavSvc.switchMap { svc -> if (svc != null) - db.homeSetDao().hasBindableByServiceLive(svc.id) + db.homeSetDao().getLiveBindableByService(svc.id) else - MutableLiveData(false) + MutableLiveData(emptyList()) + } + val canCreateAddressBook = bindableAddressBookHomesets.map { homeSets -> + homeSets.isNotEmpty() } val cardDavRefreshing = cardDavSvc.switchMap { svc -> if (svc == null) return@switchMap null - RefreshCollectionsWorker.exists(application, RefreshCollectionsWorker.workerName(svc.id)) + RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id)) } val cardDavSyncPending = BaseSyncWorker.exists( - getApplication(), + context, listOf(WorkInfo.State.ENQUEUED), account, listOf(context.getString(R.string.address_books_authority)), @@ -111,7 +123,7 @@ class AccountModel @AssistedInject constructor( } ) val cardDavSyncing = BaseSyncWorker.exists( - getApplication(), + context, listOf(WorkInfo.State.RUNNING), account, listOf(context.getString(R.string.address_books_authority)) @@ -120,20 +132,23 @@ class AccountModel @AssistedInject constructor( private val tasksProvider = TaskUtils.currentProviderLive(context) val calDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CALDAV) - val canCreateCalendar = calDavSvc.switchMap { svc -> + val bindableCalendarHomesets = calDavSvc.switchMap { svc -> if (svc != null) - db.homeSetDao().hasBindableByServiceLive(svc.id) + db.homeSetDao().getLiveBindableByService(svc.id) else - MutableLiveData(false) + MutableLiveData(emptyList()) + } + val canCreateCalendar = bindableCalendarHomesets.map { homeSets -> + homeSets.isNotEmpty() } val calDavRefreshing = calDavSvc.switchMap { svc -> if (svc == null) return@switchMap null - RefreshCollectionsWorker.exists(application, RefreshCollectionsWorker.workerName(svc.id)) + RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id)) } val calDavSyncPending = tasksProvider.switchMap { tasks -> BaseSyncWorker.exists( - getApplication(), + context, listOf(WorkInfo.State.ENQUEUED), account, listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority), @@ -145,7 +160,7 @@ class AccountModel @AssistedInject constructor( } val calDavSyncing = tasksProvider.switchMap { tasks -> BaseSyncWorker.exists( - getApplication(), + context, listOf(WorkInfo.State.RUNNING), account, listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority) @@ -155,7 +170,6 @@ class AccountModel @AssistedInject constructor( val webcalPager = CollectionPager(db, calDavSvc, Collection.TYPE_WEBCAL, showOnlyPersonal) val renameAccountError = MutableLiveData() - val deleteCollectionResult = MutableLiveData>() init { @@ -252,7 +266,6 @@ class AccountModel @AssistedInject constructor( fun onAccountRenamed(accountManager: AccountManager, oldAccount: Account, newName: String, syncIntervals: List>) { // account has now been renamed Logger.log.info("Updating account name references") - val context: Application = getApplication() // disable periodic workers of old account syncIntervals.forEach { (authority, _) -> @@ -329,9 +342,192 @@ class AccountModel @AssistedInject constructor( }, null) } + + val createCollectionResult = MutableLiveData>() + /** + * Creates a WebDAV collection using MKCOL or MKCALENDAR. + * + * @param homeSet home set into which the collection shall be created + * @param addressBook *true* if an address book shall be created, *false* if a calendar should be created + * @param name name (path segment) of the collection + */ + fun createCollection( + homeSet: HomeSet, + addressBook: Boolean, + name: String, + displayName: String?, + description: String?, + color: Int? = null, + timeZoneId: String? = null, + supportsVEVENT: Boolean? = null, + supportsVTODO: Boolean? = null, + supportsVJOURNAL: Boolean? = null + ) = viewModelScope.launch(Dispatchers.IO) { + HttpClient.Builder(context, AccountSettings(context, account)) + .setForeground(true) + .build().use { httpClient -> + try { + // delete on server + val url = homeSet.url.newBuilder() + .addPathSegment(name) + .addPathSegment("") // trailing slash + .build() + val dav = DavResource(httpClient.okHttpClient, url) + + val xml = generateMkColXml( + addressBook = addressBook, + displayName = displayName, + description = description, + color = color, + timezoneDef = timeZoneId?.let { tzId -> + DateUtils.ical4jTimeZone(tzId)?.let { tz -> + val cal = Calendar() + cal.components += tz.vTimeZone + cal.toString() + } + }, + supportsVEVENT = supportsVEVENT, + supportsVTODO = supportsVTODO, + supportsVJOURNAL = supportsVJOURNAL + ) + + dav.mkCol( + xmlBody = xml, + method = if (addressBook) "MKCOL" else "MKCALENDAR" + ) { + // success, otherwise an exception would have been thrown + } + + // no HTTP error -> create collection locally + val collection = Collection( + serviceId = homeSet.serviceId, + homeSetId = homeSet.id, + url = url, + type = if (addressBook) Collection.TYPE_ADDRESSBOOK else Collection.TYPE_CALENDAR, + displayName = displayName, + description = description + ) + db.collectionDao().insert(collection) + + // trigger service detection (because the collection may actually have other properties than the ones we have inserted) + RefreshCollectionsWorker.enqueue(context, homeSet.serviceId) + + // post success + createCollectionResult.postValue(Optional.empty()) + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't create collection", e) + // post error + createCollectionResult.postValue(Optional.of(e)) + } + } + } + + private fun generateMkColXml( + addressBook: Boolean, + displayName: String?, + description: String?, + color: Int? = null, + timezoneDef: String? = null, + supportsVEVENT: Boolean? = null, + supportsVTODO: Boolean? = null, + supportsVJOURNAL: Boolean? = null + ): String { + val writer = StringWriter() + val serializer = XmlUtils.newSerializer() + serializer.apply { + setOutput(writer) + + startDocument("UTF-8", null) + setPrefix("", NS_WEBDAV) + setPrefix("CAL", NS_CALDAV) + setPrefix("CARD", NS_CARDDAV) + + if (addressBook) + startTag(NS_WEBDAV, "mkcol") + else + startTag(NS_CALDAV, "mkcalendar") + startTag(NS_WEBDAV, "set") + startTag(NS_WEBDAV, "prop") + + startTag(NS_WEBDAV, "resourcetype") + startTag(NS_WEBDAV, "collection") + endTag(NS_WEBDAV, "collection") + if (addressBook) { + startTag(NS_CARDDAV, "addressbook") + endTag(NS_CARDDAV, "addressbook") + } else { + startTag(NS_CALDAV, "calendar") + endTag(NS_CALDAV, "calendar") + } + endTag(NS_WEBDAV, "resourcetype") + + displayName?.let { + startTag(NS_WEBDAV, "displayname") + text(it) + endTag(NS_WEBDAV, "displayname") + } + + if (addressBook) { + // addressbook-specific properties + description?.let { + startTag(NS_CARDDAV, "addressbook-description") + text(it) + endTag(NS_CARDDAV, "addressbook-description") + } + + } else { + // calendar-specific properties + description?.let { + startTag(NS_CALDAV, "calendar-description") + text(it) + endTag(NS_CALDAV, "calendar-description") + } + color?.let { + startTag(NS_APPLE_ICAL, "calendar-color") + text(DavUtils.ARGBtoCalDAVColor(it)) + endTag(NS_APPLE_ICAL, "calendar-color") + } + timezoneDef?.let { + startTag(NS_CALDAV, "calendar-timezone") + cdsect(it) + endTag(NS_CALDAV, "calendar-timezone") + } + + if (supportsVEVENT != null || supportsVTODO != null || supportsVJOURNAL != null) { + // only if there's at least one explicitly supported calendar component set, otherwise don't include the property + if (supportsVEVENT != false) { + startTag(NS_CALDAV, "comp") + attribute(null, "name", "VEVENT") + endTag(NS_CALDAV, "comp") + } + if (supportsVTODO != false) { + startTag(NS_CALDAV, "comp") + attribute(null, "name", "VTODO") + endTag(NS_CALDAV, "comp") + } + if (supportsVJOURNAL != false) { + startTag(NS_CALDAV, "comp") + attribute(null, "name", "VJOURNAL") + endTag(NS_CALDAV, "comp") + } + } + } + + endTag(NS_WEBDAV, "prop") + endTag(NS_WEBDAV, "set") + if (addressBook) + endTag(NS_WEBDAV, "mkcol") + else + endTag(NS_CALDAV, "mkcalendar") + endDocument() + } + return writer.toString() + } + + val deleteCollectionResult = MutableLiveData>() /** Deletes the given collection from the database and the server. */ fun deleteCollection(collection: Collection) = viewModelScope.launch(Dispatchers.IO) { - HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account)) + HttpClient.Builder(context, AccountSettings(context, account)) .setForeground(true) .build().use { httpClient -> try { @@ -378,7 +574,7 @@ class AccountModel @AssistedInject constructor( val interestingAuthorities = listOfNotNull( ContactsContract.AUTHORITY, CalendarContract.AUTHORITY, - TaskUtils.currentProvider(getApplication())?.authority + TaskUtils.currentProvider(context)?.authority ) val result = mutableMapOf() for (authority in interestingAuthorities) { @@ -398,7 +594,7 @@ class AccountModel @AssistedInject constructor( * @return the application name of authority (ie "jtx Board") */ private fun getAppNameFromAuthority(authority: String): String { - val packageManager = getApplication().packageManager + val packageManager = context.packageManager val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority return try { val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo @@ -413,7 +609,7 @@ class AccountModel @AssistedInject constructor( class CollectionPager( val db: AppDatabase, service: LiveData, - val collectionType: String, + private val collectionType: String, showOnlyPersonal: LiveData ) : MediatorLiveData?>() { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt index 517244a3..21a011f3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt @@ -11,48 +11,44 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField -import androidx.compose.material.RadioButton import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Alignment +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.app.TaskStackBuilder -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.HomeSet -import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.ui.AppTheme -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrl import org.apache.commons.lang3.StringUtils import java.util.UUID @@ -65,41 +61,109 @@ class CreateAddressBookActivity: AppCompatActivity() { const val EXTRA_ACCOUNT = "account" } - @Inject lateinit var modelFactory: Model.Factory - val model by viewModels { + val account by lazy { intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } + + @Inject + lateinit var modelFactory: AccountModel.Factory + val model by viewModels { object: ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - val account = intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") - return modelFactory.create(account) as T - } + override fun create(modelClass: Class): T = + modelFactory.create(account) as T } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) setContent { AppTheme { - val displayName by model.displayName.observeAsState() - val description by model.description.observeAsState() - val homeSet by model.homeSet.observeAsState() - val homeSets by model.homeSets.observeAsState() + var displayName by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var homeSet by remember { mutableStateOf(null) } - Content( - isCreateEnabled = - displayName != null && - homeSet != null, - displayName = displayName, - onDisplayNameChange = model.displayName::setValue, - description = description, - onDescriptionChange = model.description::setValue, - homeSet = homeSet, - homeSets = homeSets, - onHomeSetClicked = model.homeSet::setValue - ) + var isCreating by remember { mutableStateOf(false) } + model.createCollectionResult.observeAsState().value?.let { result -> + if (result.isEmpty) + finish() + else + ExceptionInfoDialog( + exception = result.get(), + onDismiss = { + isCreating = false + model.createCollectionResult.value = null + } + ) + } + + val onCreateCollection = { + if (!isCreating) { + isCreating = true + homeSet?.let { homeSet -> + model.createCollection( + homeSet = homeSet, + addressBook = true, + name = UUID.randomUUID().toString(), + displayName = StringUtils.trimToNull(displayName), + description = StringUtils.trimToNull(description) + ) + } + } + } + + val homeSets by model.bindableAddressBookHomesets.observeAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.create_addressbook)) }, + navigationIcon = { + IconButton(onClick = { onSupportNavigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.navigate_up)) + } + }, + actions = { + val isCreateEnabled = !isCreating && displayName.isNotEmpty() && homeSet != null + IconButton( + enabled = isCreateEnabled, + onClick = { onCreateCollection() } + ) { + Text(stringResource(R.string.create_collection_create).uppercase()) + } + } + ) + } + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + if (isCreating) + LinearProgressIndicator( + color = MaterialTheme.colors.secondary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + homeSets?.let { homeSets -> + AddressBookForm( + displayName = displayName, + onDisplayNameChange = { displayName = it }, + description = description, + onDescriptionChange = { description = it }, + homeSets = homeSets, + homeSet = homeSet, + onHomeSetSelected = { homeSet = it }, + onCreateCollection = { + onCreateCollection() + } + ) + } + } + } } } } @@ -112,191 +176,75 @@ class CreateAddressBookActivity: AppCompatActivity() { @Composable - private fun Content( - isCreateEnabled: Boolean = false, - displayName: String? = null, + private fun AddressBookForm( + displayName: String, onDisplayNameChange: (String) -> Unit = {}, - description: String? = null, + description: String, onDescriptionChange: (String) -> Unit = {}, - homeSet: HomeSet? = null, - homeSets: List? = null, - onHomeSetClicked: (HomeSet) -> Unit = {} - ) { - Scaffold( - topBar = { TopBar(isCreateEnabled) } - ) { paddingValues -> - CreateAddressBookForm( - paddingValues, - displayName, - onDisplayNameChange, - description, - onDescriptionChange, - homeSet, - homeSets, - onHomeSetClicked - ) - } - } - - @Composable - @Preview(showBackground = true, showSystemUi = true) - private fun Content_Preview() { - Content( - displayName = "Display Name", - description = "Description", - homeSets = listOf( - HomeSet(1, 0, false, "http://example.com/".toHttpUrl()), - HomeSet(2, 0, false, "http://example.com/".toHttpUrl(), displayName = "Home Set 2"), - ) - ) - } - - @Composable - private fun CreateAddressBookForm( - paddingValues: PaddingValues, - displayName: String?, - onDisplayNameChange: (String) -> Unit, - description: String?, - onDescriptionChange: (String) -> Unit, homeSet: HomeSet?, - homeSets: List?, - onHomeSetClicked: (HomeSet) -> Unit + homeSets: List, + onHomeSetSelected: (HomeSet) -> Unit = {}, + onCreateCollection: () -> Unit = {} ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - .padding(8.dp) + Column(Modifier + .fillMaxWidth() + .padding(8.dp) ) { + val focusRequester = remember { FocusRequester() } OutlinedTextField( - value = displayName ?: "", + value = displayName, onValueChange = onDisplayNameChange, label = { Text(stringResource(R.string.create_collection_display_name)) }, - modifier = Modifier.fillMaxWidth() + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } OutlinedTextField( - value = description ?: "", + value = description, onValueChange = onDescriptionChange, label = { Text(stringResource(R.string.create_collection_description_optional)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + onCreateCollection() + } + ), modifier = Modifier .fillMaxWidth() .padding(top = 8.dp) ) - Text( - text = stringResource(R.string.create_collection_home_set), - style = MaterialTheme.typography.body1, - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) + HomeSetSelection( + homeSet = homeSet, + homeSets = homeSets, + onHomeSetSelected = onHomeSetSelected ) - if (homeSets != null) { - for (item in homeSets) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = homeSet == item, - onClick = { onHomeSetClicked(item) } - ) - Text( - text = item.displayName ?: item.url.encodedPath, - style = MaterialTheme.typography.body2, - modifier = Modifier.weight(1f) - ) - } - } - } } } @Composable - private fun TopBar(isCreateEnabled: Boolean) { - TopAppBar( - title = { Text(stringResource(R.string.create_addressbook)) }, - navigationIcon = { - IconButton(onClick = ::finish) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, null) - } - }, - actions = { - IconButton( - enabled = isCreateEnabled, - onClick = ::onCreateCollection - ) { - Text(stringResource(R.string.create_collection_create).uppercase()) - } - } + @Preview + private fun AddressBookForm_Preview() { + AddressBookForm( + displayName = "Display Name", + description = "Some longer description that is optional", + homeSets = listOf( + HomeSet(1, 0, false, "http://example.com/".toHttpUrl()), + HomeSet(2, 0, false, "http://example.com/".toHttpUrl(), displayName = "Home Set 2") + ), + homeSet = null ) } - - private fun onCreateCollection() { - var ok = true - - val args = Bundle() - args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CARDDAV) - - val parent = model.homeSet.value - if (parent != null) { - args.putString( - CreateCollectionFragment.ARG_URL, - parent.url.resolve(UUID.randomUUID().toString() + "/").toString() - ) - } else { - ok = false - } - - val displayName = model.displayName.value - if (displayName.isNullOrBlank()) - ok = false - else - args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName) - - StringUtils.trimToNull(model.description.value)?.let { - args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it) - } - - if (ok) { - args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account) - args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_ADDRESSBOOK) - val frag = CreateCollectionFragment() - frag.arguments = args - frag.show(supportFragmentManager, null) - } - } - - - class Model @AssistedInject constructor( - val db: AppDatabase, - @Assisted val account: Account - ) : ViewModel() { - - @AssistedFactory - interface Factory { - fun create(account: Account): Model - } - - val displayName = MutableLiveData() - val description = MutableLiveData() - val homeSets = MutableLiveData>() - var homeSet = MutableLiveData() - - init { - viewModelScope.launch(Dispatchers.IO) { - // load account info - db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service -> - val homesets = db.homeSetDao().getBindableByService(service.id) - homeSets.postValue(homesets) - - if (homeSet.value == null) - homeSet.postValue(homesets.firstOrNull()) - } - } - } - } - -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt index 5d168283..a895a241 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt @@ -5,263 +5,430 @@ package at.bitfire.davdroid.ui.account import android.accounts.Account -import android.content.Context import android.content.Intent -import android.graphics.drawable.ColorDrawable import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.TextView +import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.NavUtils -import androidx.databinding.DataBindingUtil +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Checkbox +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.app.TaskStackBuilder import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import at.bitfire.davdroid.Constants import at.bitfire.davdroid.R -import at.bitfire.davdroid.databinding.ActivityCreateCalendarBinding -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.HomeSet -import at.bitfire.davdroid.db.Service -import at.bitfire.davdroid.ui.HomeSetAdapter -import at.bitfire.ical4android.util.DateUtils -import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.jaredrummler.android.colorpicker.ColorPickerDialogListener -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.widget.CalendarColorPickerDialog +import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog +import at.bitfire.davdroid.ui.widget.MultipleChoiceInputDialog import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import net.fortuna.ical4j.model.Calendar import org.apache.commons.lang3.StringUtils +import java.text.Collator import java.time.ZoneId import java.time.format.TextStyle import java.util.Locale -import java.util.TimeZone import java.util.UUID import javax.inject.Inject @AndroidEntryPoint -class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { +class CreateCalendarActivity: AppCompatActivity() { companion object { const val EXTRA_ACCOUNT = "account" } - @Inject lateinit var modelFactory: Model.Factory - val model by viewModels { + val account by lazy { intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } + + @Inject + lateinit var modelFactory: AccountModel.Factory + val accountModel by viewModels { object: ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - val account = intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") - return modelFactory.create(account) as T - } + override fun create(modelClass: Class): T = + modelFactory.create(account) as T } } - lateinit var binding: ActivityCreateCalendarBinding + val model: Model by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - binding = DataBindingUtil.setContentView(this, R.layout.activity_create_calendar) - binding.lifecycleOwner = this - binding.model = model + setContent { + AppTheme { + var displayName by remember { mutableStateOf("") } + var color by remember { mutableIntStateOf(Constants.DAVDROID_GREEN_RGBA) } + var description by remember { mutableStateOf("") } + var homeSet by remember { mutableStateOf(null) } + var timeZoneId by remember { mutableStateOf(ZoneId.systemDefault().id) } + var supportVEVENT by remember { mutableStateOf(true) } + var supportVTODO by remember { mutableStateOf(false) } + var supportVJOURNAL by remember { mutableStateOf(false) } - binding.color.setOnClickListener { - ColorPickerDialog.newBuilder() - .setShowAlphaSlider(false) - .setColor((binding.color.background as ColorDrawable).color) - .show(this) - } - - val homeSetAdapter = HomeSetAdapter(this) - model.homeSets.observe(this) { homeSets -> - homeSetAdapter.clear() - if (homeSets.isNotEmpty()) { - homeSetAdapter.addAll(homeSets) - val firstHomeSet = homeSets.first() - binding.homeset.setText(firstHomeSet.url.toString(), false) - model.homeSet = firstHomeSet - } - } - binding.homeset.setAdapter(homeSetAdapter) - binding.homeset.setOnItemClickListener { parent, _, position, _ -> - model.homeSet = parent.getItemAtPosition(position) as HomeSet? - } - - binding.timezone.setAdapter(TimeZoneAdapter(this)) - binding.timezone.setText(TimeZone.getDefault().id, false) - } - - override fun onColorSelected(dialogId: Int, rgb: Int) { - model.color.value = rgb - } - - override fun onDialogDismissed(dialogId: Int) { - // color selection dismissed - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_create_collection, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem) = - if (item.itemId == android.R.id.home) { - val intent = Intent(this, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account) - NavUtils.navigateUpTo(this, intent) - true - } else - false - - fun onCreateCollection(item: MenuItem) { - var ok = true - - val args = Bundle() - args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CALDAV) - - val parent = model.homeSet - if (parent != null) { - binding.homesetLayout.error = null - args.putString( - CreateCollectionFragment.ARG_URL, - parent.url.resolve(UUID.randomUUID().toString() + "/").toString() - ) - } else { - binding.homesetLayout.error = getString(R.string.create_collection_home_set_required) - ok = false - } - - val displayName = model.displayName.value - if (displayName.isNullOrBlank()) { - model.displayNameError.value = getString(R.string.create_collection_display_name_required) - ok = false - } else { - args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName) - model.displayNameError.value = null - } - - StringUtils.trimToNull(model.description.value)?.let { - args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it) - } - - model.color.value?.let { - args.putInt(CreateCollectionFragment.ARG_COLOR, it) - } - - val tzId = binding.timezone.text?.toString() - if (tzId.isNullOrBlank()) - ok = false - else { - DateUtils.ical4jTimeZone(tzId)?.let { tz -> - val cal = Calendar() - cal.components += tz.vTimeZone - args.putString(CreateCollectionFragment.ARG_TIMEZONE, cal.toString()) - } - model.timezoneError.value = null - } - - val supportsVEVENT = model.supportVEVENT.value ?: false - val supportsVTODO = model.supportVTODO.value ?: false - val supportsVJOURNAL = model.supportVJOURNAL.value ?: false - if (!supportsVEVENT && !supportsVTODO && !supportsVJOURNAL) { - ok = false - model.typeError.value = "" - } else - model.typeError.value = null - - if (supportsVEVENT || supportsVTODO || supportsVJOURNAL) { - // only if there's at least one component set not supported; don't include - // information about supported components otherwise (means: everything supported) - args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VEVENT, supportsVEVENT) - args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VTODO, supportsVTODO) - args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VJOURNAL, supportsVJOURNAL) - } - - if (ok) { - args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account) - args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_CALENDAR) - val frag = CreateCollectionFragment() - frag.arguments = args - frag.show(supportFragmentManager, null) - } - } - - - class TimeZoneAdapter(context: Context): ArrayAdapter(context, R.layout.text_list_item, android.R.id.text1) { - - init { - addAll(TimeZone.getAvailableIDs().toList()) - } - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val tzId = getItem(position)!! - val tz = ZoneId.of(tzId) - - val v: View = convertView ?: LayoutInflater.from(context).inflate(R.layout.text_list_item, parent, false) - v.findViewById(android.R.id.text1).text = tz.id - v.findViewById(android.R.id.text2).text = tz.getDisplayName(TextStyle.FULL, Locale.getDefault()) - - return v - } - - override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) = - getView(position, convertView, parent) - - } - - - class Model @AssistedInject constructor( - val db: AppDatabase, - @Assisted val account: Account - ): ViewModel() { - - @AssistedFactory - interface Factory { - fun create(account: Account): Model - } - - val displayName = MutableLiveData() - val displayNameError = MutableLiveData() - - val description = MutableLiveData() - val color = MutableLiveData() - - val homeSets = MutableLiveData>() - var homeSet: HomeSet? = null - - val timezoneError = MutableLiveData() - - val typeError = MutableLiveData() - val supportVEVENT = MutableLiveData() - val supportVTODO = MutableLiveData() - val supportVJOURNAL = MutableLiveData() - - init { - color.value = Constants.DAVDROID_GREEN_RGBA - - supportVEVENT.value = true - supportVTODO.value = true - supportVJOURNAL.value = true - - viewModelScope.launch(Dispatchers.IO) { - // load account info - db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)?.let { service -> - homeSets.postValue(db.homeSetDao().getBindableByService(service.id)) + var isCreating by remember { mutableStateOf(false) } + accountModel.createCollectionResult.observeAsState().value?.let { result -> + if (result.isEmpty) + finish() + else + ExceptionInfoDialog( + exception = result.get(), + onDismiss = { + isCreating = false + accountModel.createCollectionResult.value = null + } + ) } + + val onCreateCollection = { + if (!isCreating) { + isCreating = true + homeSet?.let { homeSet -> + accountModel.createCollection( + homeSet = homeSet, + addressBook = false, + name = UUID.randomUUID().toString(), + displayName = StringUtils.trimToNull(displayName), + description = StringUtils.trimToNull(description), + color = color, + timeZoneId = timeZoneId, + supportsVEVENT = supportVEVENT, + supportsVTODO = supportVTODO, + supportsVJOURNAL = supportVJOURNAL + ) + } + } + } + + val homeSets by accountModel.bindableCalendarHomesets.observeAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.create_calendar)) }, + navigationIcon = { + IconButton(onClick = { onSupportNavigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.navigate_up)) + } + }, + actions = { + val isCreateEnabled = !isCreating && displayName.isNotBlank() && homeSet != null + IconButton( + enabled = isCreateEnabled, + onClick = { onCreateCollection() } + ) { + Text(stringResource(R.string.create_collection_create).uppercase()) + } + } + ) + } + ) { padding -> + Column(Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + if (isCreating) + LinearProgressIndicator( + color = MaterialTheme.colors.secondary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + homeSets?.let { homeSets -> + CalendarForm( + displayName = displayName, + onDisplayNameChange = { displayName = it }, + color = color, + onColorChange = { color = it }, + description = description, + onDescriptionChange = { description = it }, + timeZoneId = timeZoneId, + onTimeZoneSelected = { timeZoneId = it }, + supportVEVENT = supportVEVENT, + onSupportVEVENTChange = { supportVEVENT = it }, + supportVTODO = supportVTODO, + onSupportVTODOChange = { supportVTODO = it }, + supportVJOURNAL = supportVJOURNAL, + onSupportVJOURNALChange = { supportVJOURNAL = it }, + homeSet = homeSet, + homeSets = homeSets, + onHomeSetSelected = { homeSet = it } + ) + } + } + } + } + } + } + + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, accountModel.account) + } + + + @Composable + fun CalendarForm( + displayName: String, + onDisplayNameChange: (String) -> Unit = {}, + color: Int, + onColorChange: (Int) -> Unit = {}, + description: String, + onDescriptionChange: (String) -> Unit = {}, + timeZoneId: String, + onTimeZoneSelected: (String) -> Unit = {}, + supportVEVENT: Boolean, + onSupportVEVENTChange: (Boolean) -> Unit = {}, + supportVTODO: Boolean, + onSupportVTODOChange: (Boolean) -> Unit = {}, + supportVJOURNAL: Boolean, + onSupportVJOURNALChange: (Boolean) -> Unit = {}, + homeSet: HomeSet?, + homeSets: List, + onHomeSetSelected: (HomeSet) -> Unit = {} + ) { + Column(Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + value = displayName, + onValueChange = onDisplayNameChange, + label = { Text(stringResource(R.string.create_collection_display_name)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + .focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + var showColorPicker by remember { mutableStateOf(false) } + Box(Modifier + .background(color = Color(color), shape = CircleShape) + .clickable { + showColorPicker = true + } + .size(32.dp) + ) + if (showColorPicker) { + CalendarColorPickerDialog( + onSelectColor = { + onColorChange(it) + showColorPicker = false + }, + onDismiss = { showColorPicker = false } + ) + } + } + + OutlinedTextField( + value = description, + onValueChange = onDescriptionChange, + label = { Text(stringResource(R.string.create_collection_description_optional)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.create_calendar_time_zone), + style = MaterialTheme.typography.body1 + ) + Text( + ZoneId.of(timeZoneId).getDisplayName(TextStyle.FULL_STANDALONE, Locale.getDefault()), + style = MaterialTheme.typography.body2 + ) + } + + var showTimeZoneDialog by remember { mutableStateOf(false) } + TextButton( + enabled = + if (LocalInspectionMode.current) + true + else + model.timeZoneDefs.observeAsState().value != null, + onClick = { showTimeZoneDialog = true } + ) { + Text("Select timezone".uppercase()) + } + if (showTimeZoneDialog) { + model.timeZoneDefs.observeAsState().value?.let { timeZoneDefs -> + MultipleChoiceInputDialog( + title = "Select timezone", + namesAndValues = timeZoneDefs, + initialValue = timeZoneId, + onValueSelected = { + onTimeZoneSelected(it) + showTimeZoneDialog = false + }, + onDismiss = { showTimeZoneDialog = false } + ) + } + } + } + + Text( + stringResource(R.string.create_calendar_type), + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(top = 16.dp) + ) + CheckboxRow( + labelId = R.string.create_calendar_type_vevent, + checked = supportVEVENT, + onCheckedChange = onSupportVEVENTChange + ) + CheckboxRow( + labelId = R.string.create_calendar_type_vtodo, + checked = supportVTODO, + onCheckedChange = onSupportVTODOChange + ) + CheckboxRow( + labelId = R.string.create_calendar_type_vjournal, + checked = supportVJOURNAL, + onCheckedChange = onSupportVJOURNALChange + ) + + HomeSetSelection( + homeSet = homeSet, + homeSets = homeSets, + onHomeSetSelected = onHomeSetSelected + ) + } + } + + @Composable + fun CheckboxRow( + @StringRes labelId: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange + ) + Text( + text = stringResource(labelId), + style = MaterialTheme.typography.body1, + modifier = Modifier + .clickable { onCheckedChange(!checked) } + .weight(1f) + ) + } + } + + @Composable + @Preview + fun CalendarForm_Preview() { + CalendarForm( + displayName = "My Calendar", + color = Color.Magenta.toArgb(), + description = "This is my calendar", + timeZoneId = "Europe/Vienna", + supportVEVENT = true, + supportVTODO = false, + supportVJOURNAL = false, + homeSet = null, + homeSets = emptyList() + ) + } + + + @HiltViewModel + class Model @Inject constructor() : ViewModel() { + + /** + * List of available time zones as pairs. + */ + val timeZoneDefs = MutableLiveData>>() + + init { + viewModelScope.launch(Dispatchers.IO) { + val timeZones = mutableListOf>() + + // iterate over Android time zones and take those with ical4j VTIMEZONE into consideration + val locale = Locale.getDefault() + for (id in ZoneId.getAvailableZoneIds()) { + timeZones += Pair( + ZoneId.of(id).getDisplayName(TextStyle.FULL, locale), + id + ) + } + + val collator = Collator.getInstance() + timeZoneDefs.postValue(timeZones.sortedBy { collator.getCollationKey(it.first) }) } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionComposables.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionComposables.kt new file mode 100644 index 00000000..587a2849 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionComposables.kt @@ -0,0 +1,65 @@ +package at.bitfire.davdroid.ui.account + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.util.DavUtils + +@Composable +fun HomeSetSelection( + homeSet: HomeSet?, + homeSets: List, + onHomeSetSelected: (HomeSet) -> Unit +) { + // select first home set if none is selected + LaunchedEffect(homeSets) { + if (homeSet == null) + homeSets.firstOrNull()?.let(onHomeSetSelected) + } + + Text( + text = stringResource(R.string.create_collection_home_set), + style = MaterialTheme.typography.body1, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 8.dp) + ) + for (item in homeSets) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = homeSet == item, + onClick = { onHomeSetSelected(item) } + ) + Column( + Modifier + .clickable { onHomeSetSelected(item) } + .weight(1f)) { + Text( + text = item.displayName ?: DavUtils.lastSegmentOfUrl(item.url), + style = MaterialTheme.typography.body1 + ) + Text( + text = item.url.encodedPath, + style = MaterialTheme.typography.caption.copy(fontFamily = FontFamily.Monospace) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt deleted file mode 100644 index c34a2d0d..00000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt +++ /dev/null @@ -1,269 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.davdroid.ui.account - -import android.accounts.Account -import android.app.Application -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import at.bitfire.dav4jvm.DavResource -import at.bitfire.dav4jvm.XmlUtils -import at.bitfire.dav4jvm.property.caldav.NS_APPLE_ICAL -import at.bitfire.dav4jvm.property.caldav.NS_CALDAV -import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV -import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.network.HttpClient -import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker -import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.ui.ExceptionInfoFragment -import at.bitfire.davdroid.util.DavUtils -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch -import okhttp3.HttpUrl.Companion.toHttpUrl -import java.io.IOException -import java.io.StringWriter -import java.util.logging.Level -import javax.inject.Inject - -@AndroidEntryPoint -class CreateCollectionFragment: DialogFragment() { - - companion object { - const val ARG_ACCOUNT = "account" - const val ARG_SERVICE_TYPE = "serviceType" - - const val ARG_TYPE = "type" - const val ARG_URL = "url" - const val ARG_DISPLAY_NAME = "displayName" - const val ARG_DESCRIPTION = "description" - - // CalDAV only - const val ARG_COLOR = "color" - const val ARG_TIMEZONE = "timezone" - const val ARG_SUPPORTS_VEVENT = "supportsVEVENT" - const val ARG_SUPPORTS_VTODO = "supportsVTODO" - const val ARG_SUPPORTS_VJOURNAL = "supportsVJOURNAL" - } - - @Inject lateinit var modelFactory: Model.Factory - val model by viewModels { - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - val args = requireArguments() - - val account: Account = args.getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required") - val serviceType = args.getString(ARG_SERVICE_TYPE) ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required") - val collection = Collection( - type = args.getString(ARG_TYPE) ?: throw IllegalArgumentException("ARG_TYPE required"), - url = (args.getString(ARG_URL) ?: throw IllegalArgumentException("ARG_URL required")).toHttpUrl(), - displayName = args.getString(ARG_DISPLAY_NAME), - description = args.getString(ARG_DESCRIPTION), - - color = args.ifDefined(ARG_COLOR) { it.getInt(ARG_COLOR) }, - timezone = args.getString(ARG_TIMEZONE), - supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { it.getBoolean(ARG_SUPPORTS_VEVENT) }, - supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { it.getBoolean(ARG_SUPPORTS_VTODO) }, - supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { it.getBoolean(ARG_SUPPORTS_VJOURNAL) }, - - sync = true /* by default, sync collections which just have been created */ - ) - - return modelFactory.create(account, serviceType, collection) as T - } - } - } - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - model.createCollection().observe(this, { exception -> - if (exception == null) - requireActivity().finish() - else { - dismiss() - parentFragmentManager.beginTransaction() - .add(ExceptionInfoFragment.newInstance(exception, model.account), null) - .commit() - } - }) - } - - private fun Bundle.ifDefined(name: String, getter: (Bundle) -> T): T? = - if (containsKey(name)) - getter(this) - else - null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val v = inflater.inflate(R.layout.create_collection, container, false) - isCancelable = false - return v - } - - - class Model @AssistedInject constructor( - application: Application, - val db: AppDatabase, - @Assisted val account: Account, - @Assisted val serviceType: String, - @Assisted val collection: Collection - ): AndroidViewModel(application) { - - @AssistedFactory - interface Factory { - fun create(account: Account, serviceType: String, collection: Collection): Model - } - - val result = MutableLiveData() - - fun createCollection(): LiveData { - viewModelScope.launch(Dispatchers.IO + NonCancellable) { - HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account)) - .setForeground(true) - .build().use { httpClient -> - try { - val dav = DavResource(httpClient.okHttpClient, collection.url) - - // create collection on remote server - dav.mkCol(generateXml()) {} - - // no HTTP error -> create collection locally - db.serviceDao().getByAccountAndType(account.name, serviceType)?.let { service -> - collection.serviceId = service.id - db.collectionDao().insert(collection) - - // trigger service detection (because the collection may have other properties than the ones we have inserted) - RefreshCollectionsWorker.enqueue(getApplication(), service.id) - } - - // post success - result.postValue(null) - } catch (e: Exception) { - // post error - result.postValue(e) - } - } - } - return result - } - - private fun generateXml(): String { - val writer = StringWriter() - try { - val serializer = XmlUtils.newSerializer() - with(serializer) { - setOutput(writer) - startDocument("UTF-8", null) - setPrefix("", NS_WEBDAV) - setPrefix("CAL", NS_CALDAV) - setPrefix("CARD", NS_CARDDAV) - - startTag(NS_WEBDAV, "mkcol") - startTag(NS_WEBDAV, "set") - startTag(NS_WEBDAV, "prop") - startTag(NS_WEBDAV, "resourcetype") - startTag(NS_WEBDAV, "collection") - endTag(NS_WEBDAV, "collection") - if (collection.type == Collection.TYPE_ADDRESSBOOK) { - startTag(NS_CARDDAV, "addressbook") - endTag(NS_CARDDAV, "addressbook") - } else if (collection.type == Collection.TYPE_CALENDAR) { - startTag(NS_CALDAV, "calendar") - endTag(NS_CALDAV, "calendar") - } - endTag(NS_WEBDAV, "resourcetype") - collection.displayName?.let { - startTag(NS_WEBDAV, "displayname") - text(it) - endTag(NS_WEBDAV, "displayname") - } - - // addressbook-specific properties - if (collection.type == Collection.TYPE_ADDRESSBOOK) { - collection.description?.let { - startTag(NS_CARDDAV, "addressbook-description") - text(it) - endTag(NS_CARDDAV, "addressbook-description") - } - } - - // calendar-specific properties - if (collection.type == Collection.TYPE_CALENDAR) { - collection.description?.let { - startTag(NS_CALDAV, "calendar-description") - text(it) - endTag(NS_CALDAV, "calendar-description") - } - - collection.color?.let { - startTag(NS_APPLE_ICAL, "calendar-color") - text(DavUtils.ARGBtoCalDAVColor(it)) - endTag(NS_APPLE_ICAL, "calendar-color") - } - - collection.timezone?.let { - startTag(NS_CALDAV, "calendar-timezone") - cdsect(it) - endTag(NS_CALDAV, "calendar-timezone") - } - - if (collection.supportsVEVENT != null || collection.supportsVTODO != null || collection.supportsVJOURNAL != null) { - // only if there's at least one explicitly supported calendar component set, otherwise don't include the property - startTag(NS_CALDAV, "supported-calendar-component-set") - if (collection.supportsVEVENT != false) { - startTag(NS_CALDAV, "comp") - attribute(null, "name", "VEVENT") - endTag(NS_CALDAV, "comp") - } - if (collection.supportsVTODO != false) { - startTag(NS_CALDAV, "comp") - attribute(null, "name", "VTODO") - endTag(NS_CALDAV, "comp") - } - if (collection.supportsVJOURNAL != false) { - startTag(NS_CALDAV, "comp") - attribute(null, "name", "VJOURNAL") - endTag(NS_CALDAV, "comp") - } - endTag(NS_CALDAV, "supported-calendar-component-set") - } - } - - endTag(NS_WEBDAV, "prop") - endTag(NS_WEBDAV, "set") - endTag(NS_WEBDAV, "mkcol") - endDocument() - } - } catch(e: IOException) { - Logger.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e) - } - - return writer.toString() - } - - } - -} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionDialog.kt index b67d56ae..ff76d62d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionDialog.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionDialog.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import at.bitfire.davdroid.R import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.ui.ExceptionInfoDialog +import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog import okhttp3.HttpUrl.Companion.toHttpUrl import java.util.Optional @@ -101,7 +101,7 @@ fun DeleteCollectionDialog_Content( ExceptionInfoDialog( exception = exception, remoteResource = collection.url, - onDismissRequest = onCancel + onDismiss = onCancel ) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountDialog.kt index bdc90f6e..fe656f33 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountDialog.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountDialog.kt @@ -33,7 +33,6 @@ fun RenameAccountDialog( onDismiss: () -> Unit = {} ) { var accountName by remember { mutableStateOf(TextFieldValue(oldName, selection = TextRange(oldName.length))) } - val focusRequester = remember { FocusRequester() } AlertDialog( onDismissRequest = onDismiss, @@ -43,6 +42,8 @@ fun RenameAccountDialog( stringResource(R.string.account_rename_new_name_description), modifier = Modifier.padding(bottom = 8.dp) ) + + val focusRequester = remember { FocusRequester() } TextField( value = accountName, onValueChange = { accountName = it }, @@ -50,15 +51,18 @@ fun RenameAccountDialog( singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, - imeAction = ImeAction.Go + imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( - onGo = { + onDone = { onRenameAccount(accountName.text) } ), modifier = Modifier.focusRequester(focusRequester) ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } }}, confirmButton = { TextButton( @@ -74,17 +78,8 @@ fun RenameAccountDialog( TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel).uppercase()) } - }, - ) - - // request focus on the first composition - var requestFocus = remember { true } - LaunchedEffect(requestFocus) { - if (requestFocus) { - focusRequester.requestFocus() - requestFocus = false } - } + ) } @Composable diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/CalendarColorPickerDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/CalendarColorPickerDialog.kt new file mode 100644 index 00000000..93028bae --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/CalendarColorPickerDialog.kt @@ -0,0 +1,58 @@ +package at.bitfire.davdroid.ui.widget + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import at.bitfire.ical4android.Css3Color + +@OptIn(ExperimentalLayoutApi::class) +@Composable +@Preview +fun CalendarColorPickerDialog( + onSelectColor: (color: Int) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val colors = remember { + Css3Color.entries.sortedBy { css3Color -> + Color(css3Color.argb).luminance() + } + } + + Dialog(onDismissRequest = onDismiss) { + Card(Modifier.verticalScroll(rememberScrollState())) { + FlowRow(Modifier.padding(8.dp)) { + for (color in colors) { + Box(Modifier.padding(2.dp)) { + Box(Modifier + .background(color = Color(color.argb), shape = CircleShape) + .clickable { onSelectColor(color.argb) } + .size(32.dp) + .padding(8.dp) + .semantics { + contentDescription = color.name + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/ExceptionInfoDialog.kt similarity index 61% rename from app/src/main/kotlin/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt rename to app/src/main/kotlin/at/bitfire/davdroid/ui/widget/ExceptionInfoDialog.kt index cdf26593..109ac132 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/ExceptionInfoDialog.kt @@ -2,11 +2,9 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.davdroid.ui +package at.bitfire.davdroid.ui.widget import android.accounts.Account -import android.app.Dialog -import android.os.Bundle import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -20,66 +18,21 @@ import androidx.compose.material.icons.rounded.Error import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.os.BundleCompat -import androidx.fragment.app.DialogFragment import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.davdroid.R -import com.google.accompanist.themeadapter.material.MdcTheme +import at.bitfire.davdroid.ui.DebugInfoActivity import okhttp3.HttpUrl import java.io.IOException -class ExceptionInfoFragment: DialogFragment() { - - companion object { - const val ARG_ACCOUNT = "account" - const val ARG_EXCEPTION = "exception" - - fun newInstance(exception: Exception, account: Account?): ExceptionInfoFragment { - val frag = ExceptionInfoFragment() - val args = Bundle(2) - args.putSerializable(ARG_EXCEPTION, exception) - args.putParcelable(ARG_ACCOUNT, account) - frag.arguments = args - return frag - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val args = requireNotNull(arguments) - val exception = args.getSerializable(ARG_EXCEPTION) as Exception - val account: Account? = BundleCompat.getParcelable(args, ARG_ACCOUNT, Account::class.java) - - val dialog = Dialog(requireContext()).apply { - setContentView( - ComposeView(requireContext()).apply { - setContent { - AppTheme { - ExceptionInfoDialog( - exception = exception, - account = account - ) { dismiss() } - } - } - } - ) - } - - isCancelable = false - return dialog - } - -} - @Composable fun ExceptionInfoDialog( exception: Throwable, account: Account? = null, remoteResource: HttpUrl? = null, - onDismissRequest: () -> Unit + onDismiss: () -> Unit ) { val context = LocalContext.current @@ -90,7 +43,7 @@ fun ExceptionInfoDialog( } AlertDialog( - onDismissRequest = onDismissRequest, + onDismissRequest = onDismiss, title = { Row( modifier = Modifier.fillMaxWidth(), @@ -124,7 +77,7 @@ fun ExceptionInfoDialog( } }, confirmButton = { - TextButton(onClick = onDismissRequest) { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.ok).uppercase()) } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/InputDialogs.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/InputDialogs.kt index 56965fb5..2f5dac1a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/InputDialogs.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/InputDialogs.kt @@ -3,11 +3,13 @@ package at.bitfire.davdroid.ui.widget import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog +import androidx.compose.material.Card import androidx.compose.material.MaterialTheme import androidx.compose.material.RadioButton import androidx.compose.material.Text @@ -29,6 +31,8 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog @Composable fun EditTextInputDialog( @@ -43,7 +47,6 @@ fun EditTextInputDialog( initialValue ?: "", selection = TextRange(initialValue?.length ?: 0) )) } - val focusRequester = remember { FocusRequester() } AlertDialog( onDismissRequest = onDismiss, @@ -54,6 +57,7 @@ fun EditTextInputDialog( ) }, text = { + val focusRequester = remember { FocusRequester() } TextField( value = textValue, onValueChange = { textValue = it }, @@ -70,6 +74,9 @@ fun EditTextInputDialog( ), modifier = Modifier.focusRequester(focusRequester) ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } }, confirmButton = { TextButton( @@ -90,14 +97,6 @@ fun EditTextInputDialog( } } ) - - var requestFocus = remember { true } - LaunchedEffect(requestFocus) { - if (requestFocus) { - focusRequester.requestFocus() - requestFocus = false - } - } } @Composable @@ -118,38 +117,51 @@ fun MultipleChoiceInputDialog( onValueSelected: (String) -> Unit = {}, onDismiss: () -> Unit = {}, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - title, - style = MaterialTheme.typography.body1 - ) - }, - text = { - Column(Modifier.verticalScroll(rememberScrollState())) { - for ((name, value) in namesAndValues) - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton( - selected = value == initialValue, - onClick = { - onValueSelected(value) - onDismiss() - } - ) - Text( - name, - style = MaterialTheme.typography.body1, - modifier = Modifier.clickable { - onValueSelected(value) - onDismiss() - } - ) - } + Dialog(onDismissRequest = onDismiss) { + Card { + Column { + Text( + title, + style = MaterialTheme.typography.body1, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + + LazyColumn(Modifier.padding(8.dp)) { + items( + count = namesAndValues.size, + key = { index -> namesAndValues[index].second }, + itemContent = { index -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + val (name, value) = namesAndValues[index] + RadioButton( + selected = value == initialValue, + onClick = { + onValueSelected(value) + onDismiss() + } + ) + Text( + name, + style = MaterialTheme.typography.body1, + modifier = Modifier + .weight(1f) + .clickable { + onValueSelected(value) + onDismiss() + } + ) + } + } + ) + } } - }, - buttons = {} - ) + } + } } @Composable diff --git a/app/src/main/res/layout/activity_create_calendar.xml b/app/src/main/res/layout/activity_create_calendar.xml deleted file mode 100644 index 4eba7bc8..00000000 --- a/app/src/main/res/layout/activity_create_calendar.xml +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/activity_create_collection.xml b/app/src/main/res/menu/activity_create_collection.xml deleted file mode 100644 index 9b642312..00000000 --- a/app/src/main/res/menu/activity_create_collection.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29156cdb..b74666d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -392,7 +392,7 @@ Create address book Create calendar - Time zone + Default time zone Possible calendar entries Events Tasks diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d72f4a3d..baccb8c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,7 +106,6 @@ dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" } hilt-android-base = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } -jaredrummler-colorpicker = { module = "com.jaredrummler:colorpicker", version.ref = "jaredrummler-colorpicker" } junit = { module = "junit:junit", version = "4.13.2" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }