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.Button
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHost
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@@ -17,6 +21,7 @@ import kotlinx.coroutines.flow.collectLatest
import net.buzzert.kordophone.backend.server.ChatRepository import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.data.AppContainer 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.theme.KordophoneTheme
import net.buzzert.kordophonedroid.ui.conversationlist.ConversationListScreen import net.buzzert.kordophonedroid.ui.conversationlist.ConversationListScreen
import net.buzzert.kordophonedroid.ui.messagelist.MessageListScreen import net.buzzert.kordophonedroid.ui.messagelist.MessageListScreen
@@ -30,7 +35,13 @@ sealed class Destination(val route: String) {
object MessageList : Destination("messages/{id}") { object MessageList : Destination("messages/{id}") {
fun createRoute(data: String) = "messages/$data" 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 @Composable
fun ErrorDialog(title: String, body: String, onDismiss: () -> Unit) { fun ErrorDialog(title: String, body: String, onDismiss: () -> Unit) {
@@ -61,29 +72,28 @@ fun KordophoneApp(
errorVisible.value = error.value errorVisible.value = error.value
} }
CompositionLocalProvider(LocalNavController provides navController) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Destination.ConversationList.route, startDestination = Destination.ConversationList.route,
) { ) {
composable(route = Destination.ConversationList.route) { composable(route = Destination.ConversationList.route) {
ConversationListScreen(onConversationSelected = { ConversationListScreen()
navController.navigate(Destination.MessageList.createRoute(it))
}, onSettingsInvoked = {
navController.navigate(Destination.Settings.route)
})
} }
composable(Destination.MessageList.route) { composable(Destination.MessageList.route) {
val conversationID = it.arguments?.getString("id")!! val conversationID = it.arguments?.getString("id")!!
MessageListScreen(conversationGUID = conversationID, backAction = { MessageListScreen(conversationGUID = conversationID)
navController.popBackStack()
})
} }
composable(Destination.Settings.route) { composable(Destination.Settings.route) {
SettingsScreen(backAction = { SettingsScreen()
navController.popBackStack() }
})
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 androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.buzzert.kordophone.backend.model.Conversation 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.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -51,14 +53,10 @@ fun formatDateTime(dateTime: LocalDateTime): String {
@Composable @Composable
fun ConversationListScreen( fun ConversationListScreen(
viewModel: ConversationListViewModel = hiltViewModel(), viewModel: ConversationListViewModel = hiltViewModel(),
onConversationSelected: (conversationID: String) -> Unit,
onSettingsInvoked: () -> Unit,
) { ) {
val conversations by viewModel.conversations.collectAsStateWithLifecycle(initialValue = emptyList()) val conversations by viewModel.conversations.collectAsStateWithLifecycle(initialValue = emptyList())
ConversationListView( ConversationListView(
conversations = conversations, conversations = conversations,
onConversationSelected = onConversationSelected,
onSettingsInvoked = onSettingsInvoked,
onRefresh = suspend { viewModel.refresh() } onRefresh = suspend { viewModel.refresh() }
) )
} }
@@ -67,8 +65,6 @@ fun ConversationListScreen(
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
fun ConversationListView( fun ConversationListView(
conversations: List<Conversation>, conversations: List<Conversation>,
onConversationSelected: (conversationID: String) -> Unit,
onSettingsInvoked: () -> Unit,
onRefresh: suspend () -> Unit, onRefresh: suspend () -> Unit,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -84,6 +80,8 @@ fun ConversationListView(
val refreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = ::refresh) val refreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = ::refresh)
val navController = LocalNavController.current
val onSettingsInvoked = { navController.navigate(Destination.Settings.route) }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar(title = { Text("Conversations") }, actions = { TopAppBar(title = { Text("Conversations") }, actions = {
@@ -101,13 +99,18 @@ fun ConversationListView(
.fillMaxSize() .fillMaxSize()
) { ) {
items(conversations) { conversation -> items(conversations) { conversation ->
val clickHandler = {
val route = Destination.MessageList.createRoute(conversation.guid)
navController.navigate(route)
}
ConversationListItem( ConversationListItem(
name = conversation.formattedDisplayName(), name = conversation.formattedDisplayName(),
id = conversation.guid, id = conversation.guid,
isUnread = conversation.unreadCount > 0, isUnread = conversation.unreadCount > 0,
lastMessagePreview = conversation.lastMessagePreview ?: "", lastMessagePreview = conversation.lastMessagePreview ?: "",
date = conversation.date, date = conversation.date,
onClick = { onConversationSelected(conversation.guid) } onClick = clickHandler
) )
} }
} }
@@ -217,5 +220,5 @@ fun ConversationListItemPreview() {
@Preview @Preview
@Composable @Composable
fun ConversationListScreenPreview() { fun ConversationListScreenPreview() {
ConversationListScreen(onConversationSelected = {}, onSettingsInvoked = {}) ConversationListScreen()
} }

View File

@@ -2,6 +2,7 @@ package net.buzzert.kordophonedroid.ui.messagelist
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import coil.Coil import coil.Coil
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
@@ -52,6 +54,10 @@ import coil.request.ImageRequest
import coil.size.Size import coil.size.Size
import net.buzzert.kordophone.backend.model.GUID import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophonedroid.R 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 net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -83,8 +89,8 @@ sealed class MessageListItem: MessageMetadataProvider {
@Composable @Composable
fun MessageListScreen( fun MessageListScreen(
conversationGUID: GUID, conversationGUID: GUID,
backAction: () -> Unit,
viewModel: MessageListViewModel = hiltViewModel(), viewModel: MessageListViewModel = hiltViewModel(),
attachmentViewModel: AttachmentViewModel = hiltViewModel() // unused, but initialized for Coil
) { ) {
viewModel.conversationGUID = conversationGUID viewModel.conversationGUID = conversationGUID
@@ -97,7 +103,7 @@ fun MessageListScreen(
val messages by viewModel.messages.collectAsStateWithLifecycle(initialValue = listOf()) val messages by viewModel.messages.collectAsStateWithLifecycle(initialValue = listOf())
var messageItems = mutableListOf<MessageListItem>() val messageItems = mutableListOf<MessageListItem>()
for (message in messages) { for (message in messages) {
val metadata = MessageMetadata( val metadata = MessageMetadata(
fromMe = message.sender == null, 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( MessageTranscript(
messages = messageItems, messages = messageItems,
paddingValues = padding, paddingValues = padding,
@@ -181,7 +191,6 @@ fun Messages(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
scrollState: LazyListState scrollState: LazyListState
) { ) {
val scope = rememberCoroutineScope()
Box(modifier = modifier) { Box(modifier = modifier) {
LazyColumn( LazyColumn(
reverseLayout = true, reverseLayout = true,
@@ -331,14 +340,13 @@ fun ImageBubble(
) { ) {
val shape: RoundedCornerShape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape val shape: RoundedCornerShape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape
val attachmentFetchData = AttachmentFetchData(guid, preview = true) val attachmentFetchData = AttachmentFetchData(guid, preview = true)
val navController = LocalNavController.current
BubbleScaffold(mine = mine, modifier = modifier) { BubbleScaffold(mine = mine, modifier = modifier) {
SubcomposeAsyncImage( SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(LocalContext.current)
.data(attachmentFetchData) .data(attachmentFetchData)
.crossfade(true) .crossfade(true)
.diskCacheKey(attachmentFetchData.guid)
.memoryCacheKey(attachmentFetchData.guid)
.build(), .build(),
loading = { loading = {
Box( Box(
@@ -357,6 +365,9 @@ fun ImageBubble(
contentDescription = "Image attachment", contentDescription = "Image attachment",
modifier = Modifier modifier = Modifier
.clip(shape) .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.GUID
import net.buzzert.kordophone.backend.model.Message import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.server.ChatRepository 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 okio.ByteString.Companion.toByteString
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@@ -48,16 +50,10 @@ import kotlin.time.Duration.Companion.seconds
const val MVM_LOG: String = "MessageListViewModel" const val MVM_LOG: String = "MessageListViewModel"
data class AttachmentFetchData(
val guid: String,
val preview: Boolean = false
)
@HiltViewModel @HiltViewModel
class MessageListViewModel @Inject constructor( class MessageListViewModel @Inject constructor(
private val repository: ChatRepository, private val repository: ChatRepository
@ApplicationContext val application: Context, ) : ViewModel()
) : ViewModel(), ImageLoaderFactory, Fetcher.Factory<AttachmentFetchData>
{ {
var conversationGUID: GUID? = null var conversationGUID: GUID? = null
set(value) { set(value) {
@@ -78,9 +74,6 @@ class MessageListViewModel @Inject constructor(
init { init {
// TODO: Need to handle settings changes here!! // TODO: Need to handle settings changes here!!
// Register Coil image loader
Coil.setImageLoader(this)
viewModelScope.launch { viewModelScope.launch {
// Remove pending message after message is delivered. // Remove pending message after message is delivered.
// By now, the repository should've committed this to the store. // By now, the repository should've committed this to the store.
@@ -129,49 +122,5 @@ class MessageListViewModel @Inject constructor(
fun synchronize() = viewModelScope.launch { fun synchronize() = viewModelScope.launch {
repository.synchronizeConversation(conversation!!, limit = 100) 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.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import net.buzzert.kordophonedroid.R import net.buzzert.kordophonedroid.R
import net.buzzert.kordophonedroid.ui.LocalNavController
import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
backAction: () -> Unit,
) { ) {
val navController = LocalNavController.current
Scaffold( Scaffold(
topBar = { topBar = {
KordophoneTopAppBar( KordophoneTopAppBar(
title = "Settings", title = "Settings",
backAction = backAction, backAction = { navController.popBackStack() },
) )
}, },
@@ -237,5 +238,5 @@ private fun EditDialog(
@Preview @Preview
@Composable @Composable
fun SettingsPreview() { fun SettingsPreview() {
SettingsScreen(backAction = {}) SettingsScreen()
} }