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) implementation(libs.commons.text)
@Suppress("RedundantSuppression") @Suppress("RedundantSuppression")
implementation(libs.dnsjava) implementation(libs.dnsjava)
implementation(libs.jaredrummler.colorpicker)
implementation(libs.mikepenz.aboutLibraries) implementation(libs.mikepenz.aboutLibraries)
implementation(libs.nsk90.kstatemachine) implementation(libs.nsk90.kstatemachine)
implementation(libs.okhttp.base) implementation(libs.okhttp.base)

View file

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

View file

@ -27,8 +27,8 @@ interface HomeSetDao {
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind") @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getBindableByService(serviceId: Long): List<HomeSet> fun getBindableByService(serviceId: Long): List<HomeSet>
@Query("SELECT COUNT(*) FROM homeset WHERE serviceId=:serviceId AND privBind") @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun hasBindableByServiceLive(serviceId: Long): LiveData<Boolean> fun getLiveBindableByService(serviceId: Long): LiveData<List<HomeSet>>
@Insert @Insert
fun insert(homeSet: HomeSet): Long 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.BuildConfig
import at.bitfire.davdroid.R import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPage 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.SettingsHeader
import at.bitfire.davdroid.ui.widget.SwitchSetting import at.bitfire.davdroid.ui.widget.SwitchSetting
import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -109,9 +109,7 @@ class AppSettingsActivity: AppCompatActivity() {
topBar = { topBar = {
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
IconButton(onClick = { IconButton(onClick = { onSupportNavigateUp() }) {
onSupportNavigateUp()
}) {
Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) 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.MainThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map import androidx.lifecycle.map
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -26,36 +26,46 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.work.WorkInfo import androidx.work.WorkInfo
import at.bitfire.dav4jvm.DavResource 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.InvalidAccountException
import at.bitfire.davdroid.R import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker
import at.bitfire.davdroid.syncadapter.BaseSyncWorker import at.bitfire.davdroid.syncadapter.BaseSyncWorker
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker 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.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.fortuna.ical4j.model.Calendar
import java.io.StringWriter
import java.util.Optional import java.util.Optional
import java.util.logging.Level import java.util.logging.Level
class AccountModel @AssistedInject constructor( class AccountModel @AssistedInject constructor(
application: Application, val context: Application,
val db: AppDatabase, val db: AppDatabase,
@Assisted val account: Account @Assisted val account: Account
): AndroidViewModel(application), OnAccountsUpdateListener { ): ViewModel(), OnAccountsUpdateListener {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -69,7 +79,7 @@ class AccountModel @AssistedInject constructor(
/** whether the account is invalid and the AccountActivity shall be closed */ /** whether the account is invalid and the AccountActivity shall be closed */
val invalid = MutableLiveData<Boolean>() val invalid = MutableLiveData<Boolean>()
private val settings = AccountSettings(application, account) private val settings = AccountSettings(context, account)
private val refreshSettingsSignal = MutableLiveData(Unit) private val refreshSettingsSignal = MutableLiveData(Unit)
val showOnlyPersonal = refreshSettingsSignal.switchMap<Unit, AccountSettings.ShowOnlyPersonal> { val showOnlyPersonal = refreshSettingsSignal.switchMap<Unit, AccountSettings.ShowOnlyPersonal> {
object : LiveData<AccountSettings.ShowOnlyPersonal>() { object : LiveData<AccountSettings.ShowOnlyPersonal>() {
@ -85,23 +95,25 @@ class AccountModel @AssistedInject constructor(
refreshSettingsSignal.postValue(Unit) refreshSettingsSignal.postValue(Unit)
} }
val context = getApplication<Application>()
val accountManager: AccountManager = AccountManager.get(context) val accountManager: AccountManager = AccountManager.get(context)
val cardDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CARDDAV) val cardDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CARDDAV)
val canCreateAddressBook = cardDavSvc.switchMap { svc -> val bindableAddressBookHomesets = cardDavSvc.switchMap { svc ->
if (svc != null) if (svc != null)
db.homeSetDao().hasBindableByServiceLive(svc.id) db.homeSetDao().getLiveBindableByService(svc.id)
else else
MutableLiveData(false) MutableLiveData(emptyList())
}
val canCreateAddressBook = bindableAddressBookHomesets.map { homeSets ->
homeSets.isNotEmpty()
} }
val cardDavRefreshing = cardDavSvc.switchMap { svc -> val cardDavRefreshing = cardDavSvc.switchMap { svc ->
if (svc == null) if (svc == null)
return@switchMap null return@switchMap null
RefreshCollectionsWorker.exists(application, RefreshCollectionsWorker.workerName(svc.id)) RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id))
} }
val cardDavSyncPending = BaseSyncWorker.exists( val cardDavSyncPending = BaseSyncWorker.exists(
getApplication(), context,
listOf(WorkInfo.State.ENQUEUED), listOf(WorkInfo.State.ENQUEUED),
account, account,
listOf(context.getString(R.string.address_books_authority)), listOf(context.getString(R.string.address_books_authority)),
@ -111,7 +123,7 @@ class AccountModel @AssistedInject constructor(
} }
) )
val cardDavSyncing = BaseSyncWorker.exists( val cardDavSyncing = BaseSyncWorker.exists(
getApplication(), context,
listOf(WorkInfo.State.RUNNING), listOf(WorkInfo.State.RUNNING),
account, account,
listOf(context.getString(R.string.address_books_authority)) listOf(context.getString(R.string.address_books_authority))
@ -120,20 +132,23 @@ class AccountModel @AssistedInject constructor(
private val tasksProvider = TaskUtils.currentProviderLive(context) private val tasksProvider = TaskUtils.currentProviderLive(context)
val calDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CALDAV) val calDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CALDAV)
val canCreateCalendar = calDavSvc.switchMap { svc -> val bindableCalendarHomesets = calDavSvc.switchMap { svc ->
if (svc != null) if (svc != null)
db.homeSetDao().hasBindableByServiceLive(svc.id) db.homeSetDao().getLiveBindableByService(svc.id)
else else
MutableLiveData(false) MutableLiveData(emptyList())
}
val canCreateCalendar = bindableCalendarHomesets.map { homeSets ->
homeSets.isNotEmpty()
} }
val calDavRefreshing = calDavSvc.switchMap { svc -> val calDavRefreshing = calDavSvc.switchMap { svc ->
if (svc == null) if (svc == null)
return@switchMap null return@switchMap null
RefreshCollectionsWorker.exists(application, RefreshCollectionsWorker.workerName(svc.id)) RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id))
} }
val calDavSyncPending = tasksProvider.switchMap { tasks -> val calDavSyncPending = tasksProvider.switchMap { tasks ->
BaseSyncWorker.exists( BaseSyncWorker.exists(
getApplication(), context,
listOf(WorkInfo.State.ENQUEUED), listOf(WorkInfo.State.ENQUEUED),
account, account,
listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority), listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority),
@ -145,7 +160,7 @@ class AccountModel @AssistedInject constructor(
} }
val calDavSyncing = tasksProvider.switchMap { tasks -> val calDavSyncing = tasksProvider.switchMap { tasks ->
BaseSyncWorker.exists( BaseSyncWorker.exists(
getApplication(), context,
listOf(WorkInfo.State.RUNNING), listOf(WorkInfo.State.RUNNING),
account, account,
listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority) listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority)
@ -155,7 +170,6 @@ class AccountModel @AssistedInject constructor(
val webcalPager = CollectionPager(db, calDavSvc, Collection.TYPE_WEBCAL, showOnlyPersonal) val webcalPager = CollectionPager(db, calDavSvc, Collection.TYPE_WEBCAL, showOnlyPersonal)
val renameAccountError = MutableLiveData<String>() val renameAccountError = MutableLiveData<String>()
val deleteCollectionResult = MutableLiveData<Optional<Exception>>()
init { init {
@ -252,7 +266,6 @@ class AccountModel @AssistedInject constructor(
fun onAccountRenamed(accountManager: AccountManager, oldAccount: Account, newName: String, syncIntervals: List<Pair<String, Long?>>) { fun onAccountRenamed(accountManager: AccountManager, oldAccount: Account, newName: String, syncIntervals: List<Pair<String, Long?>>) {
// account has now been renamed // account has now been renamed
Logger.log.info("Updating account name references") Logger.log.info("Updating account name references")
val context: Application = getApplication()
// disable periodic workers of old account // disable periodic workers of old account
syncIntervals.forEach { (authority, _) -> syncIntervals.forEach { (authority, _) ->
@ -329,9 +342,192 @@ class AccountModel @AssistedInject constructor(
}, null) }, 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. */ /** Deletes the given collection from the database and the server. */
fun deleteCollection(collection: Collection) = viewModelScope.launch(Dispatchers.IO) { fun deleteCollection(collection: Collection) = viewModelScope.launch(Dispatchers.IO) {
HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account)) HttpClient.Builder(context, AccountSettings(context, account))
.setForeground(true) .setForeground(true)
.build().use { httpClient -> .build().use { httpClient ->
try { try {
@ -378,7 +574,7 @@ class AccountModel @AssistedInject constructor(
val interestingAuthorities = listOfNotNull( val interestingAuthorities = listOfNotNull(
ContactsContract.AUTHORITY, ContactsContract.AUTHORITY,
CalendarContract.AUTHORITY, CalendarContract.AUTHORITY,
TaskUtils.currentProvider(getApplication())?.authority TaskUtils.currentProvider(context)?.authority
) )
val result = mutableMapOf<String, Long>() val result = mutableMapOf<String, Long>()
for (authority in interestingAuthorities) { for (authority in interestingAuthorities) {
@ -398,7 +594,7 @@ class AccountModel @AssistedInject constructor(
* @return the application name of authority (ie "jtx Board") * @return the application name of authority (ie "jtx Board")
*/ */
private fun getAppNameFromAuthority(authority: String): String { private fun getAppNameFromAuthority(authority: String): String {
val packageManager = getApplication<Application>().packageManager val packageManager = context.packageManager
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
return try { return try {
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
@ -413,7 +609,7 @@ class AccountModel @AssistedInject constructor(
class CollectionPager( class CollectionPager(
val db: AppDatabase, val db: AppDatabase,
service: LiveData<Service?>, service: LiveData<Service?>,
val collectionType: String, private val collectionType: String,
showOnlyPersonal: LiveData<AccountSettings.ShowOnlyPersonal> showOnlyPersonal: LiveData<AccountSettings.ShowOnlyPersonal>
) : MediatorLiveData<Pager<Int, Collection>?>() { ) : MediatorLiveData<Pager<Int, Collection>?>() {

View file

@ -11,48 +11,44 @@ import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState 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.foundation.verticalScroll
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField import androidx.compose.material.OutlinedTextField
import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TopAppBar import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.TaskStackBuilder import androidx.core.app.TaskStackBuilder
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.R 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.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.ui.AppTheme import at.bitfire.davdroid.ui.AppTheme
import dagger.assisted.Assisted import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.util.UUID import java.util.UUID
@ -65,41 +61,109 @@ class CreateAddressBookActivity: AppCompatActivity() {
const val EXTRA_ACCOUNT = "account" const val EXTRA_ACCOUNT = "account"
} }
@Inject lateinit var modelFactory: Model.Factory val account by lazy { intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") }
val model by viewModels<Model> {
@Inject
lateinit var modelFactory: AccountModel.Factory
val model by viewModels<AccountModel> {
object: ViewModelProvider.Factory { object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T =
val account = intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") modelFactory.create(account) as T
return modelFactory.create(account) as T
}
} }
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setContent { setContent {
AppTheme { AppTheme {
val displayName by model.displayName.observeAsState() var displayName by remember { mutableStateOf("") }
val description by model.description.observeAsState() var description by remember { mutableStateOf("") }
val homeSet by model.homeSet.observeAsState() var homeSet by remember { mutableStateOf<HomeSet?>(null) }
val homeSets by model.homeSets.observeAsState()
Content( var isCreating by remember { mutableStateOf(false) }
isCreateEnabled = model.createCollectionResult.observeAsState().value?.let { result ->
displayName != null && if (result.isEmpty)
homeSet != null, finish()
displayName = displayName, else
onDisplayNameChange = model.displayName::setValue, ExceptionInfoDialog(
description = description, exception = result.get(),
onDescriptionChange = model.description::setValue, onDismiss = {
homeSet = homeSet, isCreating = false
homeSets = homeSets, model.createCollectionResult.value = null
onHomeSetClicked = model.homeSet::setValue }
) )
}
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 @Composable
private fun Content( private fun AddressBookForm(
isCreateEnabled: Boolean = false, displayName: String,
displayName: String? = null,
onDisplayNameChange: (String) -> Unit = {}, onDisplayNameChange: (String) -> Unit = {},
description: String? = null, description: String,
onDescriptionChange: (String) -> Unit = {}, 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?, homeSet: HomeSet?,
homeSets: List<HomeSet>?, homeSets: List<HomeSet>,
onHomeSetClicked: (HomeSet) -> Unit onHomeSetSelected: (HomeSet) -> Unit = {},
onCreateCollection: () -> Unit = {}
) { ) {
Column( Column(Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxSize() .padding(8.dp)
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(8.dp)
) { ) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField( OutlinedTextField(
value = displayName ?: "", value = displayName,
onValueChange = onDisplayNameChange, onValueChange = onDisplayNameChange,
label = { Text(stringResource(R.string.create_collection_display_name)) }, 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( OutlinedTextField(
value = description ?: "", value = description,
onValueChange = onDescriptionChange, onValueChange = onDescriptionChange,
label = { Text(stringResource(R.string.create_collection_description_optional)) }, label = { Text(stringResource(R.string.create_collection_description_optional)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
onCreateCollection()
}
),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 8.dp) .padding(top = 8.dp)
) )
Text( HomeSetSelection(
text = stringResource(R.string.create_collection_home_set), homeSet = homeSet,
style = MaterialTheme.typography.body1, homeSets = homeSets,
modifier = Modifier onHomeSetSelected = onHomeSetSelected
.fillMaxWidth()
.padding(top = 16.dp)
) )
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 @Composable
private fun TopBar(isCreateEnabled: Boolean) { @Preview
TopAppBar( private fun AddressBookForm_Preview() {
title = { Text(stringResource(R.string.create_addressbook)) }, AddressBookForm(
navigationIcon = { displayName = "Display Name",
IconButton(onClick = ::finish) { description = "Some longer description that is optional",
Icon(Icons.AutoMirrored.Filled.ArrowBack, null) homeSets = listOf(
} HomeSet(1, 0, false, "http://example.com/".toHttpUrl()),
}, HomeSet(2, 0, false, "http://example.com/".toHttpUrl(), displayName = "Home Set 2")
actions = { ),
IconButton( homeSet = null
enabled = isCreateEnabled,
onClick = ::onCreateCollection
) {
Text(stringResource(R.string.create_collection_create).uppercase())
}
}
) )
} }
}
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 package at.bitfire.davdroid.ui.account
import android.accounts.Account import android.accounts.Account
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.ColorDrawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import androidx.activity.compose.setContent
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.viewModels import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils import androidx.compose.foundation.background
import androidx.databinding.DataBindingUtil 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.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.Constants import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R 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.HomeSet
import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.HomeSetAdapter import at.bitfire.davdroid.ui.widget.CalendarColorPickerDialog
import at.bitfire.ical4android.util.DateUtils import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog
import com.jaredrummler.android.colorpicker.ColorPickerDialog import at.bitfire.davdroid.ui.widget.MultipleChoiceInputDialog
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.fortuna.ical4j.model.Calendar
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.text.Collator
import java.time.ZoneId import java.time.ZoneId
import java.time.format.TextStyle import java.time.format.TextStyle
import java.util.Locale import java.util.Locale
import java.util.TimeZone
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { class CreateCalendarActivity: AppCompatActivity() {
companion object { companion object {
const val EXTRA_ACCOUNT = "account" const val EXTRA_ACCOUNT = "account"
} }
@Inject lateinit var modelFactory: Model.Factory val account by lazy { intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") }
val model by viewModels<Model> {
@Inject
lateinit var modelFactory: AccountModel.Factory
val accountModel by viewModels<AccountModel> {
object: ViewModelProvider.Factory { object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T =
val account = intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") modelFactory.create(account) as T
return modelFactory.create(account) as T
}
} }
} }
lateinit var binding: ActivityCreateCalendarBinding val model: Model by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding = DataBindingUtil.setContentView(this, R.layout.activity_create_calendar) setContent {
binding.lifecycleOwner = this AppTheme {
binding.model = model 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 { var isCreating by remember { mutableStateOf(false) }
ColorPickerDialog.newBuilder() accountModel.createCollectionResult.observeAsState().value?.let { result ->
.setShowAlphaSlider(false) if (result.isEmpty)
.setColor((binding.color.background as ColorDrawable).color) finish()
.show(this) else
} ExceptionInfoDialog(
exception = result.get(),
val homeSetAdapter = HomeSetAdapter(this) onDismiss = {
model.homeSets.observe(this) { homeSets -> isCreating = false
homeSetAdapter.clear() accountModel.createCollectionResult.value = null
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))
} }
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 androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection 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 okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.Optional import java.util.Optional
@ -101,7 +101,7 @@ fun DeleteCollectionDialog_Content(
ExceptionInfoDialog( ExceptionInfoDialog(
exception = exception, exception = exception,
remoteResource = collection.url, remoteResource = collection.url,
onDismissRequest = onCancel onDismiss = onCancel
) )
} }

View file

@ -33,7 +33,6 @@ fun RenameAccountDialog(
onDismiss: () -> Unit = {} onDismiss: () -> Unit = {}
) { ) {
var accountName by remember { mutableStateOf(TextFieldValue(oldName, selection = TextRange(oldName.length))) } var accountName by remember { mutableStateOf(TextFieldValue(oldName, selection = TextRange(oldName.length))) }
val focusRequester = remember { FocusRequester() }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@ -43,6 +42,8 @@ fun RenameAccountDialog(
stringResource(R.string.account_rename_new_name_description), stringResource(R.string.account_rename_new_name_description),
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
val focusRequester = remember { FocusRequester() }
TextField( TextField(
value = accountName, value = accountName,
onValueChange = { accountName = it }, onValueChange = { accountName = it },
@ -50,15 +51,18 @@ fun RenameAccountDialog(
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
imeAction = ImeAction.Go imeAction = ImeAction.Done
), ),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onGo = { onDone = {
onRenameAccount(accountName.text) onRenameAccount(accountName.text)
} }
), ),
modifier = Modifier.focusRequester(focusRequester) modifier = Modifier.focusRequester(focusRequester)
) )
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}}, }},
confirmButton = { confirmButton = {
TextButton( TextButton(
@ -74,17 +78,8 @@ fun RenameAccountDialog(
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel).uppercase()) 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 @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. * 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.accounts.Account
import android.app.Dialog
import android.os.Bundle
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.dav4jvm.exception.HttpException
import at.bitfire.davdroid.R import at.bitfire.davdroid.R
import com.google.accompanist.themeadapter.material.MdcTheme import at.bitfire.davdroid.ui.DebugInfoActivity
import okhttp3.HttpUrl import okhttp3.HttpUrl
import java.io.IOException 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 @Composable
fun ExceptionInfoDialog( fun ExceptionInfoDialog(
exception: Throwable, exception: Throwable,
account: Account? = null, account: Account? = null,
remoteResource: HttpUrl? = null, remoteResource: HttpUrl? = null,
onDismissRequest: () -> Unit onDismiss: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -90,7 +43,7 @@ fun ExceptionInfoDialog(
} }
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismiss,
title = { title = {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -124,7 +77,7 @@ fun ExceptionInfoDialog(
} }
}, },
confirmButton = { confirmButton = {
TextButton(onClick = onDismissRequest) { TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.ok).uppercase()) 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.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton import androidx.compose.material.RadioButton
import androidx.compose.material.Text 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.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Composable @Composable
fun EditTextInputDialog( fun EditTextInputDialog(
@ -43,7 +47,6 @@ fun EditTextInputDialog(
initialValue ?: "", selection = TextRange(initialValue?.length ?: 0) initialValue ?: "", selection = TextRange(initialValue?.length ?: 0)
)) ))
} }
val focusRequester = remember { FocusRequester() }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@ -54,6 +57,7 @@ fun EditTextInputDialog(
) )
}, },
text = { text = {
val focusRequester = remember { FocusRequester() }
TextField( TextField(
value = textValue, value = textValue,
onValueChange = { textValue = it }, onValueChange = { textValue = it },
@ -70,6 +74,9 @@ fun EditTextInputDialog(
), ),
modifier = Modifier.focusRequester(focusRequester) modifier = Modifier.focusRequester(focusRequester)
) )
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
@ -90,14 +97,6 @@ fun EditTextInputDialog(
} }
} }
) )
var requestFocus = remember { true }
LaunchedEffect(requestFocus) {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
} }
@Composable @Composable
@ -118,38 +117,51 @@ fun MultipleChoiceInputDialog(
onValueSelected: (String) -> Unit = {}, onValueSelected: (String) -> Unit = {},
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
) { ) {
AlertDialog( Dialog(onDismissRequest = onDismiss) {
onDismissRequest = onDismiss, Card {
title = { Column {
Text( Text(
title, title,
style = MaterialTheme.typography.body1 style = MaterialTheme.typography.body1,
) modifier = Modifier
}, .fillMaxWidth()
text = { .padding(8.dp)
Column(Modifier.verticalScroll(rememberScrollState())) { )
for ((name, value) in namesAndValues)
Row(verticalAlignment = Alignment.CenterVertically) { LazyColumn(Modifier.padding(8.dp)) {
RadioButton( items(
selected = value == initialValue, count = namesAndValues.size,
onClick = { key = { index -> namesAndValues[index].second },
onValueSelected(value) itemContent = { index ->
onDismiss() Row(
} verticalAlignment = Alignment.CenterVertically,
) modifier = Modifier.fillMaxWidth()
Text( ) {
name, val (name, value) = namesAndValues[index]
style = MaterialTheme.typography.body1, RadioButton(
modifier = Modifier.clickable { selected = value == initialValue,
onValueSelected(value) onClick = {
onDismiss() onValueSelected(value)
} onDismiss()
) }
} )
Text(
name,
style = MaterialTheme.typography.body1,
modifier = Modifier
.weight(1f)
.clickable {
onValueSelected(value)
onDismiss()
}
)
}
}
)
}
} }
}, }
buttons = {} }
)
} }
@Composable @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 --> <!-- collection management -->
<string name="create_addressbook">Create address book</string> <string name="create_addressbook">Create address book</string>
<string name="create_calendar">Create calendar</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">Possible calendar entries</string>
<string name="create_calendar_type_vevent">Events</string> <string name="create_calendar_type_vevent">Events</string>
<string name="create_calendar_type_vtodo">Tasks</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-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-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", 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" } junit = { module = "junit:junit", version = "4.13.2" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }