Initial import of f7cloud-mobile native Android app.
Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support). Current version: 0.5.113 (build 121).
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package ru.forbion.f7cloud.feature.contacts
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactDetailSheet(
|
||||
contact: ContactItem,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = F7Colors.Surface,
|
||||
) {
|
||||
ContactDetailContent(
|
||||
contact = contact,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactDetailContent(
|
||||
contact: ContactItem,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val label = contact.displayName.ifBlank { contact.email }
|
||||
val photoBytes = remember(contact.uid, contact.photoBase64) {
|
||||
ContactUi.decodeContactPhoto(contact.photoBase64)
|
||||
}
|
||||
val (avatarBg, avatarFg) = ContactUi.avatarColors(label)
|
||||
val initials = ContactUi.contactInitials(label)
|
||||
val primaryEmail = contact.emailLines.firstOrNull()
|
||||
val primaryPhone = contact.phoneLines.firstOrNull()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(88.dp)
|
||||
.clip(CircleShape)
|
||||
.background(avatarBg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (photoBytes != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(photoBytes)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
initials,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = avatarFg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
contact.displayName.ifBlank { contact.email },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
contact.subtitle?.let { subtitle ->
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryEmail != null || primaryPhone != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (primaryEmail != null) {
|
||||
F7SecondaryButton(
|
||||
text = "Email",
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$primaryEmail")),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (primaryPhone != null) {
|
||||
F7SecondaryButton(
|
||||
text = "Позвонить",
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_DIAL, Uri.parse("tel:$primaryPhone")),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(F7Colors.Background)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
if (contact.emailLines.isNotEmpty()) {
|
||||
contact.emailLines.forEachIndexed { index, email ->
|
||||
ContactDetailField(
|
||||
label = if (index == 0) "Email" else "Email ${index + 1}",
|
||||
value = email,
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (contact.phoneLines.isNotEmpty()) {
|
||||
contact.phoneLines.forEachIndexed { index, phone ->
|
||||
ContactDetailField(
|
||||
label = if (index == 0) "Телефон" else "Телефон ${index + 1}",
|
||||
value = phone,
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (contact.address.isNotBlank()) {
|
||||
ContactDetailField(label = "Адрес", value = contact.address)
|
||||
}
|
||||
if (contact.website.isNotBlank()) {
|
||||
ContactDetailField(
|
||||
label = "Сайт",
|
||||
value = contact.website,
|
||||
onClick = {
|
||||
val url = contact.website.let {
|
||||
if (it.startsWith("http://") || it.startsWith("https://")) it
|
||||
else "https://$it"
|
||||
}
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
},
|
||||
)
|
||||
}
|
||||
if (contact.birthday.isNotBlank()) {
|
||||
ContactDetailField(label = "День рождения", value = contact.birthday)
|
||||
}
|
||||
if (contact.bookName.isNotBlank()) {
|
||||
ContactDetailField(label = "Адресная книга", value = contact.bookName)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Закрыть",
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onDismiss)
|
||||
.padding(8.dp),
|
||||
color = F7Colors.Primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactDetailField(
|
||||
label: String,
|
||||
value: String,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.padding(vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (onClick != null) F7Colors.Primary else F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.5f))
|
||||
}
|
||||
|
||||
internal object ContactUi {
|
||||
private val AvatarPalette = listOf(
|
||||
androidx.compose.ui.graphics.Color(0xFFE8F5E0) to androidx.compose.ui.graphics.Color(0xFF4A7C2E),
|
||||
androidx.compose.ui.graphics.Color(0xFFFCE4EC) to androidx.compose.ui.graphics.Color(0xFFC2185B),
|
||||
androidx.compose.ui.graphics.Color(0xFFE3F2FD) to androidx.compose.ui.graphics.Color(0xFF1565C0),
|
||||
androidx.compose.ui.graphics.Color(0xFFFFF3E0) to androidx.compose.ui.graphics.Color(0xFFE65100),
|
||||
androidx.compose.ui.graphics.Color(0xFFEDE7F6) to androidx.compose.ui.graphics.Color(0xFF6B4F9B),
|
||||
)
|
||||
|
||||
fun contactInitials(name: String): String {
|
||||
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
return when {
|
||||
parts.size >= 2 -> "${parts[0].first()}${parts[1].first()}".uppercase()
|
||||
parts.size == 1 -> parts[0].take(2).uppercase()
|
||||
else -> "?"
|
||||
}
|
||||
}
|
||||
|
||||
fun avatarColors(seed: String): Pair<androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color> {
|
||||
val idx = kotlin.math.abs(seed.hashCode()) % AvatarPalette.size
|
||||
return AvatarPalette[idx]
|
||||
}
|
||||
|
||||
fun decodeContactPhoto(base64: String): ByteArray? {
|
||||
if (base64.isBlank()) return null
|
||||
return runCatching {
|
||||
android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
||||
}.getOrNull()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package ru.forbion.f7cloud.feature.contacts
|
||||
|
||||
object ContactRecipientHelper {
|
||||
fun formatRecipient(displayName: String, email: String): String {
|
||||
val name = displayName.trim()
|
||||
val mail = email.trim()
|
||||
if (mail.isBlank()) return name
|
||||
if (name.isBlank() || name.equals(mail, ignoreCase = true)) return mail
|
||||
return "$name <$mail>"
|
||||
}
|
||||
|
||||
fun currentToken(raw: String): String {
|
||||
val tail = raw.substringAfterLast(',', raw).substringAfterLast(';', raw)
|
||||
return tail.trim()
|
||||
}
|
||||
|
||||
fun replaceCurrentToken(raw: String, replacement: String): String {
|
||||
val comma = raw.lastIndexOf(',')
|
||||
val semicolon = raw.lastIndexOf(';')
|
||||
val sepIndex = maxOf(comma, semicolon)
|
||||
if (sepIndex < 0) return replacement
|
||||
val prefix = raw.substring(0, sepIndex + 1)
|
||||
return if (prefix.endsWith(' ')) {
|
||||
"$prefix$replacement"
|
||||
} else {
|
||||
"$prefix $replacement"
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package ru.forbion.f7cloud.feature.contacts
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.OcsUserResolver
|
||||
import ru.forbion.f7cloud.core.database.ContactEntity
|
||||
import ru.forbion.f7cloud.core.database.F7Database
|
||||
import ru.forbion.f7cloud.core.network.CardDavClient
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
|
||||
data class ContactItem(
|
||||
val uid: String,
|
||||
val displayName: String,
|
||||
val email: String,
|
||||
val phone: String,
|
||||
val bookName: String,
|
||||
val photoBase64: String = "",
|
||||
val photoMimeType: String = "",
|
||||
val organization: String = "",
|
||||
val title: String = "",
|
||||
val address: String = "",
|
||||
val website: String = "",
|
||||
val birthday: String = "",
|
||||
val emails: String = "",
|
||||
val phones: String = "",
|
||||
) {
|
||||
val emailLines: List<String>
|
||||
get() = (if (emails.isNotBlank()) emails else email)
|
||||
.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
val phoneLines: List<String>
|
||||
get() = (if (phones.isNotBlank()) phones else phone)
|
||||
.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
val subtitle: String?
|
||||
get() = when {
|
||||
title.isNotBlank() && organization.isNotBlank() -> "$title · $organization"
|
||||
title.isNotBlank() -> title
|
||||
organization.isNotBlank() -> organization
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
class ContactsRepository(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val dao = F7Database.get(appContext).contactsDao()
|
||||
private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun observeContacts(session: AuthSession): Flow<List<ContactItem>> {
|
||||
return dao.observeAll(accountKey(session)).map { entities ->
|
||||
entities.map { it.toItem() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCachedContacts(session: AuthSession): List<ContactItem> {
|
||||
return dao.getAll(accountKey(session)).map { it.toItem() }
|
||||
}
|
||||
|
||||
suspend fun syncContacts(session: AuthSession, force: Boolean = false): List<ContactItem> {
|
||||
val key = accountKey(session)
|
||||
val lastSync = prefs.getLong(lastSyncKey(key), 0L)
|
||||
val needsPhotoBackfill = !prefs.getBoolean(photoSyncDoneKey(key), false)
|
||||
val needsDetailsBackfill = !prefs.getBoolean(detailsSyncDoneKey(key), false)
|
||||
if (!force && !needsPhotoBackfill && !needsDetailsBackfill &&
|
||||
System.currentTimeMillis() - lastSync < SYNC_INTERVAL_MS
|
||||
) {
|
||||
return dao.getAll(key).map { it.toItem() }
|
||||
}
|
||||
val remote = fetchRemoteContacts(session)
|
||||
val entities = remote.map { contact ->
|
||||
ContactEntity(
|
||||
accountKey = key,
|
||||
uid = contact.uid.ifBlank { "${contact.email}|${contact.displayName}" },
|
||||
displayName = contact.displayName,
|
||||
email = contact.email,
|
||||
phone = contact.phone,
|
||||
bookName = contact.bookName,
|
||||
photoBase64 = contact.photoBase64,
|
||||
photoMimeType = contact.photoMimeType,
|
||||
organization = contact.organization,
|
||||
title = contact.title,
|
||||
address = contact.address,
|
||||
website = contact.website,
|
||||
birthday = contact.birthday,
|
||||
emails = contact.emails,
|
||||
phones = contact.phones,
|
||||
)
|
||||
}
|
||||
dao.replaceAll(key, entities)
|
||||
prefs.edit()
|
||||
.putLong(lastSyncKey(key), System.currentTimeMillis())
|
||||
.putBoolean(photoSyncDoneKey(key), true)
|
||||
.putBoolean(detailsSyncDoneKey(key), true)
|
||||
.apply()
|
||||
return entities.map { it.toItem() }
|
||||
}
|
||||
|
||||
suspend fun createContact(
|
||||
session: AuthSession,
|
||||
displayName: String,
|
||||
email: String,
|
||||
phone: String = "",
|
||||
): ContactItem {
|
||||
val client = authedClient(session)
|
||||
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
|
||||
val created = CardDavClient.createContact(
|
||||
client = client,
|
||||
serverUrl = session.serverUrl,
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
email = email,
|
||||
phone = phone,
|
||||
)
|
||||
val key = accountKey(session)
|
||||
val entity = ContactEntity(
|
||||
accountKey = key,
|
||||
uid = created.uid,
|
||||
displayName = created.displayName,
|
||||
email = created.email,
|
||||
phone = created.phone,
|
||||
bookName = created.bookName,
|
||||
photoBase64 = created.photoBase64,
|
||||
photoMimeType = created.photoMimeType,
|
||||
)
|
||||
dao.insert(entity)
|
||||
return entity.toItem()
|
||||
}
|
||||
|
||||
fun filterSuggestions(
|
||||
contacts: List<ContactItem>,
|
||||
query: String,
|
||||
limit: Int = 12,
|
||||
): List<ContactItem> {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) return emptyList()
|
||||
return contacts
|
||||
.filter {
|
||||
it.displayName.contains(q, ignoreCase = true) ||
|
||||
it.email.contains(q, ignoreCase = true)
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<ContactItem> { !it.displayName.startsWith(q, ignoreCase = true) }
|
||||
.thenBy { !it.email.startsWith(q, ignoreCase = true) }
|
||||
.thenBy { it.displayName.lowercase() },
|
||||
)
|
||||
.take(limit)
|
||||
}
|
||||
|
||||
private suspend fun fetchRemoteContacts(session: AuthSession): List<ContactItem> {
|
||||
val client = authedClient(session)
|
||||
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
|
||||
return CardDavClient.listContacts(client, session.serverUrl, userId)
|
||||
.map {
|
||||
ContactItem(
|
||||
uid = it.uid,
|
||||
displayName = it.displayName,
|
||||
email = it.email,
|
||||
phone = it.phone,
|
||||
bookName = it.bookName,
|
||||
photoBase64 = it.photoBase64,
|
||||
photoMimeType = it.photoMimeType,
|
||||
organization = it.organization,
|
||||
title = it.title,
|
||||
address = it.address,
|
||||
website = it.website,
|
||||
birthday = it.birthday,
|
||||
emails = it.emails,
|
||||
phones = it.phones,
|
||||
)
|
||||
}
|
||||
.sortedBy { it.displayName.lowercase() }
|
||||
}
|
||||
|
||||
private fun authedClient(session: AuthSession) = NetworkFactory.newAuthedClient(
|
||||
session.username,
|
||||
session.appPassword,
|
||||
session.trustAllCerts,
|
||||
)
|
||||
|
||||
private fun accountKey(session: AuthSession): String =
|
||||
"${session.serverUrl}|${session.username}"
|
||||
|
||||
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
|
||||
|
||||
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
|
||||
|
||||
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
|
||||
|
||||
private fun ContactEntity.toItem() = ContactItem(
|
||||
uid = uid,
|
||||
displayName = displayName,
|
||||
email = email,
|
||||
phone = phone,
|
||||
bookName = bookName,
|
||||
photoBase64 = photoBase64,
|
||||
photoMimeType = photoMimeType,
|
||||
organization = organization,
|
||||
title = title,
|
||||
address = address,
|
||||
website = website,
|
||||
birthday = birthday,
|
||||
emails = emails,
|
||||
phones = phones,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val SYNC_INTERVAL_MS = 10 * 60 * 1000L
|
||||
private const val PREFS_NAME = "contacts_sync"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package ru.forbion.f7cloud.feature.contacts
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
session: AuthSession,
|
||||
modifier: Modifier = Modifier,
|
||||
createRequest: Int = 0,
|
||||
onUnauthorized: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val vm: ContactsViewModel = viewModel(factory = ContactsViewModelFactory(context))
|
||||
val state by vm.state.collectAsState()
|
||||
|
||||
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
|
||||
vm.load(session)
|
||||
}
|
||||
LaunchedEffect(createRequest) {
|
||||
if (createRequest > 0) vm.openAddSheet()
|
||||
}
|
||||
LaunchedEffect(state.unauthorized) {
|
||||
if (state.unauthorized) onUnauthorized()
|
||||
}
|
||||
|
||||
F7ModuleScreen(
|
||||
modifier = modifier,
|
||||
loading = state.loading && state.contacts.isEmpty(),
|
||||
error = state.error,
|
||||
) {
|
||||
ContactsSearchBar(
|
||||
serverUrl = session.serverUrl,
|
||||
query = state.searchQuery,
|
||||
onQueryChange = vm::setSearchQuery,
|
||||
)
|
||||
if (state.syncing && state.contacts.isNotEmpty()) {
|
||||
Text(
|
||||
"Обновление…",
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
||||
ContactListRow(
|
||||
contact = contact,
|
||||
onClick = { vm.openContact(contact) },
|
||||
)
|
||||
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.selectedContact?.let { contact ->
|
||||
ContactDetailSheet(
|
||||
contact = contact,
|
||||
onDismiss = vm::closeContactDetail,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.addSheetOpen) {
|
||||
AddContactDialog(
|
||||
saving = state.savingContact,
|
||||
error = state.addError,
|
||||
onDismiss = vm::closeAddSheet,
|
||||
onSave = { name, email, phone ->
|
||||
vm.createContact(session, name, email, phone)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddContactDialog(
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (name: String, email: String, phone: String) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var phone by remember { mutableStateOf("") }
|
||||
|
||||
Dialog(onDismissRequest = { if (!saving) onDismiss() }) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.White)
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Новый контакт",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
AddContactField(label = "Имя", value = name, onValueChange = { name = it })
|
||||
AddContactField(label = "Email", value = email, onValueChange = { email = it })
|
||||
AddContactField(label = "Телефон", value = phone, onValueChange = { phone = it })
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(error, color = MaterialTheme.colorScheme.error, fontSize = 13.sp)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Отмена",
|
||||
modifier = Modifier
|
||||
.clickable(enabled = !saving, onClick = onDismiss)
|
||||
.padding(8.dp),
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(F7Colors.Primary)
|
||||
.clickable(enabled = !saving) { onSave(name, email, phone) }
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (saving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text("Сохранить", color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddContactField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, fontSize = 13.sp, color = F7Colors.TextSecondary)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
|
||||
cursorBrush = SolidColor(F7Colors.Primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactListRow(
|
||||
contact: ContactItem,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val label = contact.displayName.ifBlank { contact.email }
|
||||
val (bg, fg) = ContactUi.avatarColors(label)
|
||||
val initials = ContactUi.contactInitials(label)
|
||||
val photoBytes = remember(contact.uid, contact.photoBase64) {
|
||||
ContactUi.decodeContactPhoto(contact.photoBase64)
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 4.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(bg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (photoBytes != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(photoBytes)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
initials,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = fg,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
contact.displayName.ifBlank { contact.email },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (contact.email.isNotBlank() && contact.displayName.isNotBlank()) {
|
||||
Text(
|
||||
contact.email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else if (contact.phone.isNotBlank()) {
|
||||
Text(
|
||||
contact.phone,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactsSearchBar(
|
||||
serverUrl: String,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
.height(40.dp)
|
||||
.shadow(2.dp, RoundedCornerShape(100.dp), spotColor = Color(0xFFCBCBCB))
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(Color.White)
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp))
|
||||
.padding(horizontal = 14.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = "$base/themes/forbion/images/search/searchContacts.svg",
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp),
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
),
|
||||
cursorBrush = SolidColor(F7Colors.Primary),
|
||||
decorationBox = { inner ->
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
"Поиск контактов",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
color = F7Colors.TextSecondary,
|
||||
),
|
||||
)
|
||||
}
|
||||
inner()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package ru.forbion.f7cloud.feature.contacts
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
|
||||
data class ContactsUiState(
|
||||
val loading: Boolean = false,
|
||||
val syncing: Boolean = false,
|
||||
val contacts: List<ContactItem> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val error: String? = null,
|
||||
val unauthorized: Boolean = false,
|
||||
val addSheetOpen: Boolean = false,
|
||||
val savingContact: Boolean = false,
|
||||
val addError: String? = null,
|
||||
val selectedContact: ContactItem? = null,
|
||||
) {
|
||||
val filteredContacts: List<ContactItem>
|
||||
get() {
|
||||
val q = searchQuery.trim()
|
||||
if (q.isEmpty()) return contacts
|
||||
return contacts.filter {
|
||||
it.displayName.contains(q, ignoreCase = true) ||
|
||||
it.email.contains(q, ignoreCase = true) ||
|
||||
it.phone.contains(q, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ContactsViewModel(
|
||||
private val repository: ContactsRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(ContactsUiState())
|
||||
val state: StateFlow<ContactsUiState> = _state.asStateFlow()
|
||||
|
||||
private var periodicSyncJob: Job? = null
|
||||
private var activeSession: AuthSession? = null
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_state.update { it.copy(searchQuery = query) }
|
||||
}
|
||||
|
||||
fun openAddSheet() {
|
||||
_state.update { it.copy(addSheetOpen = true, addError = null) }
|
||||
}
|
||||
|
||||
fun closeAddSheet() {
|
||||
_state.update { it.copy(addSheetOpen = false, addError = null, savingContact = false) }
|
||||
}
|
||||
|
||||
fun openContact(contact: ContactItem) {
|
||||
_state.update { it.copy(selectedContact = contact) }
|
||||
}
|
||||
|
||||
fun closeContactDetail() {
|
||||
_state.update { it.copy(selectedContact = null) }
|
||||
}
|
||||
|
||||
fun load(session: AuthSession) {
|
||||
if (activeSession?.serverUrl == session.serverUrl &&
|
||||
activeSession?.username == session.username
|
||||
) {
|
||||
return
|
||||
}
|
||||
activeSession = session
|
||||
periodicSyncJob?.cancel()
|
||||
viewModelScope.launch {
|
||||
repository.observeContacts(session).collect { contacts ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
contacts = contacts,
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
refresh(session, showLoading = true)
|
||||
periodicSyncJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
delay(ContactsRepository.SYNC_INTERVAL_MS)
|
||||
if (AppForegroundTracker.isForeground) {
|
||||
refresh(session, showLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(session: AuthSession, showLoading: Boolean = false) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (showLoading && _state.value.contacts.isEmpty()) {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
} else {
|
||||
_state.update { it.copy(syncing = true, error = null) }
|
||||
}
|
||||
runCatching { repository.syncContacts(session) }
|
||||
.onFailure { t ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
syncing = false,
|
||||
error = if (it.contacts.isEmpty()) t.message else null,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
syncing = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createContact(
|
||||
session: AuthSession,
|
||||
displayName: String,
|
||||
email: String,
|
||||
phone: String,
|
||||
) {
|
||||
if (_state.value.savingContact) return
|
||||
val name = displayName.trim()
|
||||
val mail = email.trim()
|
||||
if (name.isBlank() && mail.isBlank()) {
|
||||
_state.update { it.copy(addError = "Укажите имя или email") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.update { it.copy(savingContact = true, addError = null) }
|
||||
runCatching {
|
||||
repository.createContact(
|
||||
session = session,
|
||||
displayName = name.ifBlank { mail.substringBefore('@') },
|
||||
email = mail,
|
||||
phone = phone.trim(),
|
||||
)
|
||||
}.onSuccess {
|
||||
_state.update {
|
||||
it.copy(
|
||||
savingContact = false,
|
||||
addSheetOpen = false,
|
||||
addError = null,
|
||||
)
|
||||
}
|
||||
refresh(session, showLoading = false)
|
||||
}.onFailure { t ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
savingContact = false,
|
||||
addError = t.message ?: "Не удалось создать контакт",
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ContactsViewModelFactory(
|
||||
context: Context,
|
||||
) : ViewModelProvider.Factory {
|
||||
private val repository = ContactsRepository(context.applicationContext)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(ContactsViewModel::class.java)) {
|
||||
return ContactsViewModel(repository) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user