Rewrite CreateCalendarActivity to Compose (#645)

* Added color picker dialog

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Removed title

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Migrated to Jetpack Compose

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Removed unused layouts

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed activity name

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed duplicated title

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Cleanup

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Error tweaks

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Rewrite Create address book, Create collection, Delete collection

* [WIP] Create calendar: more properties; use own color picker (other one was M3)

* [WIP] Create calendar

* Add missing properties for calendars

* Support color, remove comments

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Arnau Mora 2024-03-20 11:40:03 +01:00 committed by GitHub
parent fea33ab60a
commit d37718c58a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 951 additions and 984 deletions

View File

@ -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)

View File

@ -150,7 +150,7 @@
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.CreateCalendarActivity"
android:label="@string/create_calendar"
android:theme="@style/AppTheme.NoActionBar"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.AccountSettingsActivity"

View File

@ -27,8 +27,8 @@ interface HomeSetDao {
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getBindableByService(serviceId: Long): List<HomeSet>
@Query("SELECT COUNT(*) FROM homeset WHERE serviceId=:serviceId AND privBind")
fun hasBindableByServiceLive(serviceId: Long): LiveData<Boolean>
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getLiveBindableByService(serviceId: Long): LiveData<List<HomeSet>>
@Insert
fun insert(homeSet: HomeSet): Long

View File

@ -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))
}
},

View File

@ -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<Boolean>()
private val settings = AccountSettings(application, account)
private val settings = AccountSettings(context, account)
private val refreshSettingsSignal = MutableLiveData(Unit)
val showOnlyPersonal = refreshSettingsSignal.switchMap<Unit, AccountSettings.ShowOnlyPersonal> {
object : LiveData<AccountSettings.ShowOnlyPersonal>() {
@ -85,23 +95,25 @@ class AccountModel @AssistedInject constructor(
refreshSettingsSignal.postValue(Unit)
}
val context = getApplication<Application>()
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<String>()
val deleteCollectionResult = MutableLiveData<Optional<Exception>>()
init {
@ -252,7 +266,6 @@ class AccountModel @AssistedInject constructor(
fun onAccountRenamed(accountManager: AccountManager, oldAccount: Account, newName: String, syncIntervals: List<Pair<String, Long?>>) {
// 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<Optional<Exception>>()
/**
* 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<Optional<Exception>>()
/** 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<String, Long>()
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<Application>().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<Service?>,
val collectionType: String,
private val collectionType: String,
showOnlyPersonal: LiveData<AccountSettings.ShowOnlyPersonal>
) : MediatorLiveData<Pager<Int, Collection>?>() {

View File

@ -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<Model> {
val account by lazy { intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") }
@Inject
lateinit var modelFactory: AccountModel.Factory
val model by viewModels<AccountModel> {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val account = intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set")
return modelFactory.create(account) as T
}
override fun <T : ViewModel> create(modelClass: Class<T>): 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<HomeSet?>(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<HomeSet>? = 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<HomeSet>?,
onHomeSetClicked: (HomeSet) -> Unit
homeSets: List<HomeSet>,
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<String>()
val description = MutableLiveData<String>()
val homeSets = MutableLiveData<List<HomeSet>>()
var homeSet = MutableLiveData<HomeSet>()
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())
}
}
}
}
}
}

View File

@ -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<Model> {
val account by lazy { intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") }
@Inject
lateinit var modelFactory: AccountModel.Factory
val accountModel by viewModels<AccountModel> {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val account = intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set")
return modelFactory.create(account) as T
}
override fun <T : ViewModel> create(modelClass: Class<T>): 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<HomeSet?>(null) }
var timeZoneId by remember { mutableStateOf<String>(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<String>(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<TextView>(android.R.id.text1).text = tz.id
v.findViewById<TextView>(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<String>()
val displayNameError = MutableLiveData<String>()
val description = MutableLiveData<String>()
val color = MutableLiveData<Int>()
val homeSets = MutableLiveData<List<HomeSet>>()
var homeSet: HomeSet? = null
val timezoneError = MutableLiveData<String>()
val typeError = MutableLiveData<String>()
val supportVEVENT = MutableLiveData<Boolean>()
val supportVTODO = MutableLiveData<Boolean>()
val supportVJOURNAL = MutableLiveData<Boolean>()
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<HomeSet>,
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 <display name, ID> pairs.
*/
val timeZoneDefs = MutableLiveData<List<Pair<String, String>>>()
init {
viewModelScope.launch(Dispatchers.IO) {
val timeZones = mutableListOf<Pair<String, String>>()
// 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) })
}
}

View File

@ -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<HomeSet>,
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)
)
}
}
}
}

View File

@ -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<Model> {
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): 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<T> 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<Exception>()
fun createCollection(): LiveData<Exception> {
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()
}
}
}

View File

@ -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
)
}

View File

@ -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

View File

@ -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
}
)
}
}
}
}
}
}

View File

@ -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())
}
}

View File

@ -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

View File

@ -1,144 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".ui.account.CreateCalendarActivity">
<data>
<import type="android.view.View"/>
<variable
name="model"
type="at.bitfire.davdroid.ui.account.CreateCalendarActivity.Model"/>
</data>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/activity_margin">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/display_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/create_collection_display_name"
app:error="@{model.displayNameError}"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:layout_constraintHorizontal_weight="1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/color">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@={model.displayName}" />
</com.google.android.material.textfield.TextInputLayout>
<View
android:id="@+id/color"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginLeft="8dp"
android:background="@{model.color}"
android:contentDescription="@string/create_collection_color"
app:layout_constraintStart_toEndOf="@id/display_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/display_name"
app:layout_constraintBottom_toBottomOf="@id/display_name"/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/create_collection_description"
app:helperText="@string/create_collection_optional"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/display_name">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@={model.description}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/homeset_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/description"
android:layout_marginTop="8dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:hint="@string/create_collection_home_set">
<AutoCompleteTextView
android:id="@+id/homeset"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/timezone_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/homeset_layout"
android:layout_marginTop="8dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:hint="@string/create_calendar_time_zone"
app:error="@{model.timezoneError}">
<AutoCompleteTextView
android:id="@+id/timezone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/create_calendar_type"
app:error="@{model.typeError}"
app:layout_constraintTop_toBottomOf="@id/timezone_layout"
app:layout_constraintStart_toStartOf="parent"/>
<com.google.android.flexbox.FlexboxLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:flexWrap="wrap"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/type">
<CheckBox
android:id="@+id/support_vevent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:checked="@={model.supportVEVENT}"
android:text="@string/create_calendar_type_vevent"/>
<CheckBox
android:id="@+id/support_vtodo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:checked="@={model.supportVTODO}"
android:text="@string/create_calendar_type_vtodo"/>
<CheckBox
android:id="@+id/support_vjournal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:checked="@={model.supportVJOURNAL}"
android:text="@string/create_calendar_type_vjournal"/>
</com.google.android.flexbox.FlexboxLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</layout>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/create"
android:title="@string/create_collection_create"
android:onClick="onCreateCollection"
app:showAsAction="always"/>
</menu>

View File

@ -392,7 +392,7 @@
<!-- collection management -->
<string name="create_addressbook">Create address book</string>
<string name="create_calendar">Create calendar</string>
<string name="create_calendar_time_zone">Time zone</string>
<string name="create_calendar_time_zone">Default time zone</string>
<string name="create_calendar_type">Possible calendar entries</string>
<string name="create_calendar_type_vevent">Events</string>
<string name="create_calendar_type_vtodo">Tasks</string>

View File

@ -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" }