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:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -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() }
}
}
@@ -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"
}
}
}
@@ -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()
},
)
}
}
}
@@ -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")
}
}