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