Skip to content

Commit d34123b

Browse files
Merge pull request #5943 from nextcloud/feat/noid/profileComposable
⚒️🎨 Migrate Profile to Composable
2 parents abf7f86 + 6564f27 commit d34123b

18 files changed

Lines changed: 1396 additions & 1303 deletions
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
package com.nextcloud.talk.profile
8+
9+
import android.content.Context
10+
import androidx.compose.foundation.isSystemInDarkTheme
11+
import androidx.compose.foundation.layout.Arrangement
12+
import androidx.compose.foundation.layout.Column
13+
import androidx.compose.foundation.layout.Row
14+
import androidx.compose.foundation.layout.Spacer
15+
import androidx.compose.foundation.layout.height
16+
import androidx.compose.foundation.layout.padding
17+
import androidx.compose.foundation.layout.size
18+
import androidx.compose.foundation.shape.CircleShape
19+
import androidx.compose.foundation.shape.RoundedCornerShape
20+
import androidx.compose.material3.FilledTonalIconButton
21+
import androidx.compose.material3.Icon
22+
import androidx.compose.material3.MaterialTheme
23+
import androidx.compose.material3.Text
24+
import androidx.compose.runtime.Composable
25+
import androidx.compose.runtime.LaunchedEffect
26+
import androidx.compose.runtime.remember
27+
import androidx.compose.runtime.rememberCoroutineScope
28+
import androidx.compose.ui.Alignment
29+
import androidx.compose.ui.Modifier
30+
import androidx.compose.ui.draw.clip
31+
import androidx.compose.ui.layout.ContentScale
32+
import androidx.compose.ui.platform.LocalContext
33+
import androidx.compose.ui.res.painterResource
34+
import androidx.compose.ui.res.stringResource
35+
import androidx.compose.ui.text.style.TextOverflow
36+
import androidx.compose.ui.unit.Dp
37+
import androidx.compose.ui.unit.dp
38+
import coil.annotation.ExperimentalCoilApi
39+
import coil.compose.AsyncImage
40+
import coil.compose.AsyncImagePainter
41+
import coil.imageLoader
42+
import coil.memory.MemoryCache
43+
import coil.request.CachePolicy
44+
import coil.request.ImageRequest
45+
import com.nextcloud.talk.R
46+
import com.nextcloud.talk.utils.ApiUtils
47+
import kotlinx.coroutines.CoroutineScope
48+
import kotlinx.coroutines.Dispatchers
49+
import kotlinx.coroutines.launch
50+
51+
@OptIn(ExperimentalCoilApi::class)
52+
@Composable
53+
private fun AvatarImage(state: ProfileUiState, avatarSize: Dp) {
54+
val context = LocalContext.current
55+
val user = state.currentUser
56+
val isDark = isSystemInDarkTheme()
57+
val url = ApiUtils.getUrlForAvatar(user?.baseUrl, user?.userId, requestBigSize = true, darkMode = isDark)
58+
val cachePolicy = if (state.avatarRefreshKey > 0) CachePolicy.WRITE_ONLY else CachePolicy.ENABLED
59+
val model = remember(url, state.avatarRefreshKey) {
60+
ImageRequest.Builder(context)
61+
.data(url)
62+
.memoryCachePolicy(cachePolicy)
63+
.diskCachePolicy(cachePolicy)
64+
.crossfade(true)
65+
.build()
66+
}
67+
val coroutineScope = rememberCoroutineScope()
68+
AvatarCacheEffect(state, isDark)
69+
AsyncImage(
70+
model = model,
71+
contentDescription = stringResource(R.string.avatar),
72+
contentScale = ContentScale.Crop,
73+
placeholder = painterResource(R.drawable.account_circle_96dp),
74+
error = painterResource(R.drawable.account_circle_96dp),
75+
onSuccess = { successState ->
76+
if (state.avatarRefreshKey > 0 && !state.avatarIsDeleted) {
77+
val otherUrl = ApiUtils.getUrlForAvatar(
78+
user?.baseUrl,
79+
user?.userId,
80+
requestBigSize = true,
81+
darkMode = !isDark
82+
)
83+
copyAvatarToOtherThemeCache(successState, context, otherUrl, url, coroutineScope)
84+
}
85+
},
86+
modifier = Modifier
87+
.size(avatarSize)
88+
.clip(CircleShape)
89+
)
90+
}
91+
92+
/**
93+
* Side-effect composable that evicts stale avatar cache entries and, for the delete case,
94+
* prefetches the other theme's server-generated avatar whenever [ProfileUiState.avatarRefreshKey]
95+
* changes.
96+
*/
97+
@OptIn(ExperimentalCoilApi::class)
98+
@Composable
99+
private fun AvatarCacheEffect(state: ProfileUiState, isDark: Boolean) {
100+
val context = LocalContext.current
101+
val user = state.currentUser
102+
LaunchedEffect(state.avatarRefreshKey) {
103+
if (state.avatarRefreshKey > 0) {
104+
val imageLoader = context.imageLoader
105+
val urlLight =
106+
ApiUtils.getUrlForAvatar(user?.baseUrl, user?.userId, requestBigSize = true, darkMode = false)
107+
val urlDark = ApiUtils.getUrlForAvatar(user?.baseUrl, user?.userId, requestBigSize = true, darkMode = true)
108+
// Evict both theme variants so no stale image survives in either cache layer.
109+
listOf(urlLight, urlDark).forEach { variantUrl ->
110+
imageLoader.memoryCache?.let { cache ->
111+
cache.keys.filter { it.key == variantUrl }.forEach { cache.remove(it) }
112+
}
113+
imageLoader.diskCache?.remove(variantUrl)
114+
}
115+
// Delete: server returns different theme-aware avatars per URL, so the other theme
116+
// must be fetched independently via a dedicated network request.
117+
if (state.avatarIsDeleted) {
118+
val otherUrl = if (isDark) urlLight else urlDark
119+
imageLoader.enqueue(
120+
ImageRequest.Builder(context)
121+
.data(otherUrl)
122+
.memoryCachePolicy(CachePolicy.WRITE_ONLY)
123+
.diskCachePolicy(CachePolicy.WRITE_ONLY)
124+
.build()
125+
)
126+
}
127+
// Upload/picker/camera: the other theme's cache is populated via onSuccess once the
128+
// current theme's image has been fetched, avoiding a redundant network request.
129+
}
130+
}
131+
}
132+
133+
/**
134+
* Copies the freshly loaded avatar into the opposite theme's cache slots (memory + disk)
135+
* preventing the need for a second network request.
136+
*/
137+
@OptIn(ExperimentalCoilApi::class)
138+
private fun copyAvatarToOtherThemeCache(
139+
successState: AsyncImagePainter.State.Success,
140+
context: Context,
141+
otherUrl: String,
142+
url: String,
143+
coroutineScope: CoroutineScope
144+
) {
145+
val imageLoader = context.imageLoader
146+
// Copy memory-cache entry, preserving key extras (e.g. resolved image size).
147+
val currentMemKey = successState.result.memoryCacheKey
148+
val memValue = currentMemKey?.let { imageLoader.memoryCache?.get(it) }
149+
if (currentMemKey != null && memValue != null) {
150+
imageLoader.memoryCache?.set(MemoryCache.Key(otherUrl, currentMemKey.extras), memValue)
151+
}
152+
// Copy disk-cache bytes on a background thread.
153+
val diskKey = successState.result.diskCacheKey ?: url
154+
coroutineScope.launch(Dispatchers.IO) {
155+
val diskCache = imageLoader.diskCache ?: return@launch
156+
diskCache.openSnapshot(diskKey)?.use { snapshot ->
157+
diskCache.openEditor(otherUrl)?.let { editor ->
158+
try {
159+
java.io.File(snapshot.data.toString())
160+
.copyTo(java.io.File(editor.data.toString()), overwrite = true)
161+
java.io.File(snapshot.metadata.toString())
162+
.copyTo(java.io.File(editor.metadata.toString()), overwrite = true)
163+
editor.commitAndOpenSnapshot()?.close()
164+
} catch (_: Exception) {
165+
editor.abort()
166+
}
167+
}
168+
}
169+
}
170+
}
171+
172+
@Composable
173+
fun AvatarSection(state: ProfileUiState, callbacks: ProfileCallbacks, modifier: Modifier) {
174+
Column(modifier = modifier.padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
175+
AvatarImage(state, 96.dp)
176+
if (state.showAvatarButtons) {
177+
AvatarButtonsRow(callbacks = callbacks, modifier = Modifier.padding(top = 8.dp, bottom = 8.dp))
178+
}
179+
if (state.displayName.isNotEmpty()) {
180+
Spacer(modifier = Modifier.height(8.dp))
181+
Text(
182+
text = state.displayName,
183+
style = MaterialTheme.typography.titleLarge,
184+
maxLines = 1,
185+
overflow = TextOverflow.Ellipsis,
186+
modifier = Modifier.padding(horizontal = 16.dp)
187+
)
188+
}
189+
if (state.baseUrl.isNotEmpty()) {
190+
Text(
191+
text = state.baseUrl,
192+
style = MaterialTheme.typography.bodyMedium,
193+
color = MaterialTheme.colorScheme.onSurfaceVariant,
194+
maxLines = 2,
195+
overflow = TextOverflow.Ellipsis,
196+
modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)
197+
)
198+
}
199+
}
200+
}
201+
202+
@Composable
203+
private fun AvatarButtonsRow(callbacks: ProfileCallbacks, modifier: Modifier = Modifier) {
204+
val buttonShape = RoundedCornerShape(12.dp)
205+
Row(
206+
modifier = modifier,
207+
horizontalArrangement = Arrangement.spacedBy(8.dp)
208+
) {
209+
FilledTonalIconButton(
210+
onClick = callbacks.onAvatarUploadClick,
211+
modifier = Modifier.size(40.dp),
212+
shape = buttonShape
213+
) {
214+
Icon(
215+
painter = painterResource(R.drawable.upload),
216+
contentDescription = stringResource(R.string.upload_new_avatar_from_device)
217+
)
218+
}
219+
FilledTonalIconButton(
220+
onClick = callbacks.onAvatarChooseClick,
221+
modifier = Modifier.size(40.dp),
222+
shape = buttonShape
223+
) {
224+
Icon(
225+
painter = painterResource(R.drawable.ic_folder),
226+
contentDescription = stringResource(R.string.choose_avatar_from_cloud)
227+
)
228+
}
229+
FilledTonalIconButton(
230+
onClick = callbacks.onAvatarCameraClick,
231+
modifier = Modifier.size(40.dp),
232+
shape = buttonShape
233+
) {
234+
Icon(
235+
painter = painterResource(R.drawable.ic_baseline_photo_camera_24),
236+
contentDescription = stringResource(R.string.set_avatar_from_camera)
237+
)
238+
}
239+
FilledTonalIconButton(
240+
onClick = callbacks.onAvatarDeleteClick,
241+
modifier = Modifier.size(40.dp),
242+
shape = buttonShape
243+
) {
244+
Icon(
245+
painter = painterResource(R.drawable.trashbin),
246+
contentDescription = stringResource(R.string.delete_avatar)
247+
)
248+
}
249+
}
250+
}

0 commit comments

Comments
 (0)