Adds attachment viewer when clicking on an attachment
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user