Private
Public Access
1
0

Adds attachment viewer when clicking on an attachment

This commit is contained in:
2024-03-23 18:09:13 -07:00
parent 9f5f2d7af5
commit 9f37b57876
8 changed files with 189 additions and 94 deletions

View File

@@ -4,12 +4,16 @@ import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHost
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@@ -17,6 +21,7 @@ import kotlinx.coroutines.flow.collectLatest
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.data.AppContainer
import net.buzzert.kordophonedroid.ui.attachments.AttachmentViewer
import net.buzzert.kordophonedroid.ui.theme.KordophoneTheme
import net.buzzert.kordophonedroid.ui.conversationlist.ConversationListScreen
import net.buzzert.kordophonedroid.ui.messagelist.MessageListScreen
@@ -30,8 +35,14 @@ sealed class Destination(val route: String) {
object MessageList : Destination("messages/{id}") {
fun createRoute(data: String) = "messages/$data"
}
object AttachmentViewer : Destination("attachment/{guid}") {
fun createRoute(guid: String) = "attachment/$guid"
}
}
val LocalNavController = compositionLocalOf<NavHostController> { error("No nav host") }
@Composable
fun ErrorDialog(title: String, body: String, onDismiss: () -> Unit) {
AlertDialog(
@@ -61,29 +72,28 @@ fun KordophoneApp(
errorVisible.value = error.value
}
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
startDestination = Destination.ConversationList.route,
) {
composable(route = Destination.ConversationList.route) {
ConversationListScreen(onConversationSelected = {
navController.navigate(Destination.MessageList.createRoute(it))
}, onSettingsInvoked = {
navController.navigate(Destination.Settings.route)
})
ConversationListScreen()
}
composable(Destination.MessageList.route) {
val conversationID = it.arguments?.getString("id")!!
MessageListScreen(conversationGUID = conversationID, backAction = {
navController.popBackStack()
})
MessageListScreen(conversationGUID = conversationID)
}
composable(Destination.Settings.route) {
SettingsScreen(backAction = {
navController.popBackStack()
})
SettingsScreen()
}
composable(Destination.AttachmentViewer.route) {
val guid = it.arguments?.getString("guid")!!
AttachmentViewer(attachmentGuid = guid)
}
}
}

View File

@@ -0,0 +1,83 @@
package net.buzzert.kordophonedroid.ui.attachments
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import coil.Coil
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.memory.MemoryCache
import coil.request.Options
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.ui.messagelist.MVM_LOG
import javax.inject.Inject
data class AttachmentFetchData(
val guid: String,
val preview: Boolean = false
)
@HiltViewModel
class AttachmentViewModel @Inject constructor(
private val repository: ChatRepository,
@ApplicationContext val application: Context,
) : ViewModel(), ImageLoaderFactory, Fetcher.Factory<AttachmentFetchData>
{
init {
// Register Coil image loader
Coil.setImageLoader(this)
}
override fun newImageLoader(): ImageLoader {
val factory = this
return ImageLoader.Builder(application)
.memoryCache {
MemoryCache.Builder(application)
.maxSizePercent(0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(application.cacheDir.resolve("attachments"))
.maxSizePercent(0.02)
.build()
}
.components {
// Adds the FetcherFactory
add(factory)
}
.build()
}
override fun create(
data: AttachmentFetchData,
options: Options,
imageLoader: ImageLoader
): Fetcher {
return AttachmentFetcher(repository, application, data)
}
}
private class AttachmentFetcher(
val repository: ChatRepository,
val context: Context,
val data: AttachmentFetchData
): Fetcher {
override suspend fun fetch(): FetchResult {
Log.d(MVM_LOG, "Loading attachment ${data.guid} from network")
val source = repository.fetchAttachmentDataSource(data.guid, data.preview)
return SourceResult(
source = ImageSource(source, context),
dataSource = DataSource.NETWORK,
mimeType = null,
)
}
}

View File

@@ -0,0 +1,38 @@
package net.buzzert.kordophonedroid.ui.attachments
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import net.buzzert.kordophonedroid.ui.LocalNavController
import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
@Composable
fun AttachmentViewer(
attachmentGuid: String,
attachmentViewModel: AttachmentViewModel = hiltViewModel()
) {
val navController = LocalNavController.current
Scaffold(topBar = {
KordophoneTopAppBar(
title = "Attachment",
backAction = { navController.popBackStack() }
)
}) { padding ->
val data = AttachmentFetchData(attachmentGuid, preview = false)
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(data)
.crossfade(true)
.build(),
contentDescription = "",
modifier = Modifier
.padding(padding)
)
}
}

View File

@@ -32,6 +32,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophonedroid.ui.Destination
import net.buzzert.kordophonedroid.ui.LocalNavController
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
@@ -51,14 +53,10 @@ fun formatDateTime(dateTime: LocalDateTime): String {
@Composable
fun ConversationListScreen(
viewModel: ConversationListViewModel = hiltViewModel(),
onConversationSelected: (conversationID: String) -> Unit,
onSettingsInvoked: () -> Unit,
) {
val conversations by viewModel.conversations.collectAsStateWithLifecycle(initialValue = emptyList())
ConversationListView(
conversations = conversations,
onConversationSelected = onConversationSelected,
onSettingsInvoked = onSettingsInvoked,
onRefresh = suspend { viewModel.refresh() }
)
}
@@ -67,8 +65,6 @@ fun ConversationListScreen(
@OptIn(ExperimentalMaterialApi::class)
fun ConversationListView(
conversations: List<Conversation>,
onConversationSelected: (conversationID: String) -> Unit,
onSettingsInvoked: () -> Unit,
onRefresh: suspend () -> Unit,
) {
val listState = rememberLazyListState()
@@ -84,6 +80,8 @@ fun ConversationListView(
val refreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = ::refresh)
val navController = LocalNavController.current
val onSettingsInvoked = { navController.navigate(Destination.Settings.route) }
Scaffold(
topBar = {
TopAppBar(title = { Text("Conversations") }, actions = {
@@ -101,13 +99,18 @@ fun ConversationListView(
.fillMaxSize()
) {
items(conversations) { conversation ->
val clickHandler = {
val route = Destination.MessageList.createRoute(conversation.guid)
navController.navigate(route)
}
ConversationListItem(
name = conversation.formattedDisplayName(),
id = conversation.guid,
isUnread = conversation.unreadCount > 0,
lastMessagePreview = conversation.lastMessagePreview ?: "",
date = conversation.date,
onClick = { onConversationSelected(conversation.guid) }
onClick = clickHandler
)
}
}
@@ -217,5 +220,5 @@ fun ConversationListItemPreview() {
@Preview
@Composable
fun ConversationListScreenPreview() {
ConversationListScreen(onConversationSelected = {}, onSettingsInvoked = {})
ConversationListScreen()
}

View File

@@ -2,6 +2,7 @@ package net.buzzert.kordophonedroid.ui.messagelist
import android.util.Log
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
@@ -42,6 +43,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import coil.Coil
import coil.ImageLoader
import coil.ImageLoaderFactory
@@ -52,6 +54,10 @@ import coil.request.ImageRequest
import coil.size.Size
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophonedroid.R
import net.buzzert.kordophonedroid.ui.Destination
import net.buzzert.kordophonedroid.ui.LocalNavController
import net.buzzert.kordophonedroid.ui.attachments.AttachmentFetchData
import net.buzzert.kordophonedroid.ui.attachments.AttachmentViewModel
import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -83,8 +89,8 @@ sealed class MessageListItem: MessageMetadataProvider {
@Composable
fun MessageListScreen(
conversationGUID: GUID,
backAction: () -> Unit,
viewModel: MessageListViewModel = hiltViewModel(),
attachmentViewModel: AttachmentViewModel = hiltViewModel() // unused, but initialized for Coil
) {
viewModel.conversationGUID = conversationGUID
@@ -97,7 +103,7 @@ fun MessageListScreen(
val messages by viewModel.messages.collectAsStateWithLifecycle(initialValue = listOf())
var messageItems = mutableListOf<MessageListItem>()
val messageItems = mutableListOf<MessageListItem>()
for (message in messages) {
val metadata = MessageMetadata(
fromMe = message.sender == null,
@@ -125,7 +131,11 @@ fun MessageListScreen(
}
}
Scaffold(topBar = { KordophoneTopAppBar(title = viewModel.title, backAction = backAction) }) { padding ->
val navController = LocalNavController.current
Scaffold(
topBar = {
KordophoneTopAppBar(title = viewModel.title, backAction = { navController.popBackStack() })
}) { padding ->
MessageTranscript(
messages = messageItems,
paddingValues = padding,
@@ -181,7 +191,6 @@ fun Messages(
modifier: Modifier = Modifier,
scrollState: LazyListState
) {
val scope = rememberCoroutineScope()
Box(modifier = modifier) {
LazyColumn(
reverseLayout = true,
@@ -331,14 +340,13 @@ fun ImageBubble(
) {
val shape: RoundedCornerShape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape
val attachmentFetchData = AttachmentFetchData(guid, preview = true)
val navController = LocalNavController.current
BubbleScaffold(mine = mine, modifier = modifier) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(attachmentFetchData)
.crossfade(true)
.diskCacheKey(attachmentFetchData.guid)
.memoryCacheKey(attachmentFetchData.guid)
.build(),
loading = {
Box(
@@ -357,6 +365,9 @@ fun ImageBubble(
contentDescription = "Image attachment",
modifier = Modifier
.clip(shape)
.clickable {
navController.navigate(Destination.AttachmentViewer.createRoute(guid))
}
)
}
}

View File

@@ -35,6 +35,8 @@ import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.ui.attachments.AttachmentFetchData
import net.buzzert.kordophonedroid.ui.attachments.AttachmentViewModel
import okio.ByteString.Companion.toByteString
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
@@ -48,16 +50,10 @@ import kotlin.time.Duration.Companion.seconds
const val MVM_LOG: String = "MessageListViewModel"
data class AttachmentFetchData(
val guid: String,
val preview: Boolean = false
)
@HiltViewModel
class MessageListViewModel @Inject constructor(
private val repository: ChatRepository,
@ApplicationContext val application: Context,
) : ViewModel(), ImageLoaderFactory, Fetcher.Factory<AttachmentFetchData>
private val repository: ChatRepository
) : ViewModel()
{
var conversationGUID: GUID? = null
set(value) {
@@ -78,9 +74,6 @@ class MessageListViewModel @Inject constructor(
init {
// TODO: Need to handle settings changes here!!
// Register Coil image loader
Coil.setImageLoader(this)
viewModelScope.launch {
// Remove pending message after message is delivered.
// By now, the repository should've committed this to the store.
@@ -129,49 +122,5 @@ class MessageListViewModel @Inject constructor(
fun synchronize() = viewModelScope.launch {
repository.synchronizeConversation(conversation!!, limit = 100)
}
override fun newImageLoader(): ImageLoader {
val factory = this
return ImageLoader.Builder(application)
.memoryCache {
MemoryCache.Builder(application)
.maxSizePercent(0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(application.cacheDir.resolve("attachments"))
.maxSizePercent(0.02)
.build()
}
.components {
// Adds the FetcherFactory
add(factory)
}
.build()
}
override fun create(
data: AttachmentFetchData,
options: Options,
imageLoader: ImageLoader
): Fetcher {
return AttachmentFetcher(repository, application, data)
}
}
private class AttachmentFetcher(
val repository: ChatRepository,
val context: Context,
val data: AttachmentFetchData
): Fetcher {
override suspend fun fetch(): FetchResult {
Log.d(MVM_LOG, "Loading attachment ${data.guid} from network")
val source = repository.fetchAttachmentDataSource(data.guid, data.preview)
return SourceResult(
source = ImageSource(source, context),
dataSource = DataSource.NETWORK,
mimeType = null,
)
}
}

View File

@@ -49,18 +49,19 @@ import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import net.buzzert.kordophonedroid.R
import net.buzzert.kordophonedroid.ui.LocalNavController
import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
backAction: () -> Unit,
) {
val navController = LocalNavController.current
Scaffold(
topBar = {
KordophoneTopAppBar(
title = "Settings",
backAction = backAction,
backAction = { navController.popBackStack() },
)
},
@@ -237,5 +238,5 @@ private fun EditDialog(
@Preview
@Composable
fun SettingsPreview() {
SettingsScreen(backAction = {})
SettingsScreen()
}