OXYGENHUB
# aa21eb5e0e5b oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / common / AvatarImage.kt
4.0 KiB · last changed Your avatar on the dashboard and the Account screen (closes #13) 10 days ago source blame history raw
1
package io.oxygenhub.android.ui.common
2
3
import android.graphics.BitmapFactory
4
import androidx.compose.foundation.Canvas
5
import androidx.compose.foundation.Image
6
import androidx.compose.foundation.layout.Box
7
import androidx.compose.foundation.layout.fillMaxSize
8
import androidx.compose.foundation.layout.size
9
import androidx.compose.foundation.shape.CircleShape
10
import androidx.compose.material3.MaterialTheme
11
import androidx.compose.material3.Surface
12
import androidx.compose.material3.Text
13
import androidx.compose.runtime.Composable
14
import androidx.compose.runtime.getValue
15
import androidx.compose.runtime.produceState
16
import androidx.compose.runtime.remember
17
import androidx.compose.ui.Alignment
18
import androidx.compose.ui.Modifier
19
import androidx.compose.ui.draw.clip
20
import androidx.compose.ui.geometry.Offset
21
import androidx.compose.ui.geometry.Size
22
import androidx.compose.ui.graphics.Color
23
import androidx.compose.ui.graphics.asImageBitmap
24
import androidx.compose.ui.layout.ContentScale
25
import androidx.compose.ui.semantics.contentDescription
26
import androidx.compose.ui.semantics.semantics
27
import androidx.compose.ui.unit.Dp
28
import androidx.compose.ui.unit.dp
29
import io.oxygenhub.android.api.Avatar
30
import io.oxygenhub.android.api.HubApi
31
import kotlinx.coroutines.CancellationException
32
import java.util.concurrent.ConcurrentHashMap
33
34
/** Avatars already fetched this run, by account name: each screen shows the last one at once. */
35
object Avatars {
36
    private val known = ConcurrentHashMap<String, Avatar>()
37
38
    fun cached(name: String): Avatar? = known[name.lowercase()]
39
40
    /** Fetch `name`'s avatar again; on a failure the one fetched before, if any. */
41
    suspend fun load(api: HubApi, name: String): Avatar? {
42
        val fresh = try {
43
            api.avatar(name)
44
        } catch (e: CancellationException) {
45
            throw e
46
        } catch (_: Exception) {
47
            return cached(name)
48
        }
49
        if (fresh == null) known.remove(name.lowercase()) else known[name.lowercase()] = fresh
50
        return fresh
51
    }
52
}
53
54
/** `name`'s avatar: the cached one first, then the hub's. */
55
@Composable
56
fun rememberAvatar(api: HubApi, name: String): Avatar? {
57
    val avatar by produceState(Avatars.cached(name), api, name) { value = Avatars.load(api, name) }
58
    return avatar
59
}
60
61
/**
62
 * An account's avatar in a circle: its picture, the hub's identicon drawn
63
 * cell by cell, or its initial while there is neither.
64
 */
65
@Composable
66
fun AvatarImage(avatar: Avatar?, name: String, modifier: Modifier = Modifier, size: Dp = 40.dp) {
67
    val described = modifier.size(size).clip(CircleShape).semantics { contentDescription = "$name's avatar" }
68
    when (avatar) {
69
        is Avatar.Picture -> {
70
            val bitmap = remember(avatar) {
71
                BitmapFactory.decodeByteArray(avatar.bytes, 0, avatar.bytes.size)?.asImageBitmap()
72
            }
73
            if (bitmap == null) {
74
                Initial(name, size, described)
75
            } else {
76
                Image(bitmap, contentDescription = null, contentScale = ContentScale.Crop, modifier = described)
77
            }
78
        }
79
        is Avatar.Identicon -> Canvas(described) {
80
            // The hub's 7 by 7 field, so the pattern has the same margin.
81
            val cell = this.size.width / 7f
82
            drawRect(Color.hsl(avatar.hue.toFloat(), 0.45f, 0.92f))
83
            val ink = Color.hsl(avatar.hue.toFloat(), 0.65f, 0.45f)
84
            for ((x, y) in avatar.cells) {
85
                drawRect(ink, topLeft = Offset(x * cell, y * cell), size = Size(cell, cell))
86
            }
87
        }
88
        null -> Initial(name, size, described)
89
    }
90
}
91
92
@Composable
93
private fun Initial(name: String, size: Dp, modifier: Modifier) {
94
    Surface(modifier, color = MaterialTheme.colorScheme.primaryContainer) {
95
        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
96
            Text(
97
                name.take(1).uppercase(),
98
                style = if (size >= 56.dp) MaterialTheme.typography.headlineMedium else MaterialTheme.typography.titleMedium,
99
                color = MaterialTheme.colorScheme.onPrimaryContainer,
100
            )
101
        }
102
    }
103
}