From 9f37b5787651931cffbf5523e82fbe30997f22ae Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 23 Mar 2024 18:09:13 -0700 Subject: [PATCH] Adds attachment viewer when clicking on an attachment --- .../kordophonedroid/ui/KordophoneApp.kt | 52 +++++++----- .../ui/attachments/AttachmentViewModel.kt | 83 +++++++++++++++++++ .../ui/attachments/AttachmentViewer.kt | 38 +++++++++ .../ConversationListScreen.kt | 19 +++-- .../ui/messagelist/MessageListScreen.kt | 23 +++-- .../ui/messagelist/MessageListViewModel.kt | 59 +------------ .../ui/settings/SettingsScreen.kt | 7 +- .../buzzert/kordophone/backend/MockServer.kt | 2 +- 8 files changed, 189 insertions(+), 94 deletions(-) create mode 100644 app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewModel.kt create mode 100644 app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewer.kt diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt index 5972ddc..35fc62d 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt @@ -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 { error("No nav host") } + @Composable fun ErrorDialog(title: String, body: String, onDismiss: () -> Unit) { AlertDialog( @@ -61,29 +72,28 @@ fun KordophoneApp( errorVisible.value = error.value } - 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) - }) - } + CompositionLocalProvider(LocalNavController provides navController) { + NavHost( + navController = navController, + startDestination = Destination.ConversationList.route, + ) { + composable(route = Destination.ConversationList.route) { + ConversationListScreen() + } - composable(Destination.MessageList.route) { - val conversationID = it.arguments?.getString("id")!! - MessageListScreen(conversationGUID = conversationID, backAction = { - navController.popBackStack() - }) - } + composable(Destination.MessageList.route) { + val conversationID = it.arguments?.getString("id")!! + MessageListScreen(conversationGUID = conversationID) + } - composable(Destination.Settings.route) { - SettingsScreen(backAction = { - navController.popBackStack() - }) + composable(Destination.Settings.route) { + SettingsScreen() + } + + composable(Destination.AttachmentViewer.route) { + val guid = it.arguments?.getString("guid")!! + AttachmentViewer(attachmentGuid = guid) + } } } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewModel.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewModel.kt new file mode 100644 index 0000000..356c831 --- /dev/null +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewModel.kt @@ -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 +{ + 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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewer.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewer.kt new file mode 100644 index 0000000..cc76bf3 --- /dev/null +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewer.kt @@ -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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt index 6125423..1d45194 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt @@ -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, - 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() } \ No newline at end of file diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt index b605c0b..3602794 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt @@ -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() + val messageItems = mutableListOf() 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)) + } ) } } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt index a2569ca..936df13 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt @@ -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 + 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, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsScreen.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsScreen.kt index 4c44e00..412b2c3 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsScreen.kt @@ -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() } \ No newline at end of file diff --git a/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt b/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt index 574cb89..782a0b0 100644 --- a/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt +++ b/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt @@ -264,7 +264,7 @@ class MockServerInterface(private val server: MockServer): APIInterface { return Response.success(null) } - override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody { + override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody { TODO("Not yet implemented") }