Private
Public Access
1
0

More pleasant OOTB experience

This commit is contained in:
2024-04-10 23:35:54 -07:00
parent d474ce1c10
commit 6ed8e88bf0
7 changed files with 159 additions and 67 deletions

View File

@@ -2,12 +2,29 @@ package net.buzzert.kordophonedroid.ui.conversationlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.pullrefresh.PullRefreshIndicator
@@ -27,10 +44,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophonedroid.R
import net.buzzert.kordophonedroid.ui.Destination
import net.buzzert.kordophonedroid.ui.LocalNavController
import java.time.LocalDate
@@ -54,8 +72,12 @@ fun ConversationListScreen(
viewModel: ConversationListViewModel = hiltViewModel(),
) {
val conversations by viewModel.conversations.collectAsStateWithLifecycle(initialValue = emptyList())
val encounteredError by viewModel.encounteredConnectionError
ConversationListView(
conversations = conversations,
isConfigured = viewModel.isServerConfigured,
encounteredError = encounteredError,
onRefresh = suspend { viewModel.refresh() }
)
}
@@ -64,6 +86,8 @@ fun ConversationListScreen(
@OptIn(ExperimentalMaterialApi::class)
fun ConversationListView(
conversations: List<Conversation>,
isConfigured: Boolean = true,
encounteredError: Boolean = false,
onRefresh: suspend () -> Unit,
) {
val listState = rememberLazyListState()
@@ -77,6 +101,8 @@ fun ConversationListView(
refreshing = false
}
val showErrorScreen = conversations.isEmpty() && encounteredError
val refreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = ::refresh)
val navController = LocalNavController.current
@@ -90,35 +116,49 @@ fun ConversationListView(
})
}
) {
Box(Modifier.pullRefresh(refreshState)) {
LazyColumn(
state = listState,
modifier = Modifier
.padding(it)
.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 = clickHandler
)
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter),
if (showErrorScreen) {
NoContentView(
icon = R.drawable.error,
text = "Connection error",
onSettings = onSettingsInvoked
)
} else if (!isConfigured) {
NoContentView(
icon = R.drawable.storage,
text = "Server not configured",
onSettings = onSettingsInvoked
)
} else {
Box(Modifier.pullRefresh(refreshState)) {
LazyColumn(
state = listState,
modifier = Modifier
.padding(it)
.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 = clickHandler
)
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
}
}

View File

@@ -1,26 +1,22 @@
package net.buzzert.kordophonedroid.ui.conversationlist
import android.util.Log
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.cache
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.server.APIClientFactory
import net.buzzert.kordophone.backend.server.Authentication
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophone.backend.server.RetrofitAPIClient
import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.URL
import javax.inject.Inject
const val CL_VM_LOG: String = "ConversationListViewModel"
@@ -38,6 +34,14 @@ class ConversationListViewModel @Inject constructor(
.reversed()
}
val isServerConfigured: Boolean
get() = chatRepository.isConfigured
val encounteredConnectionError: State<Boolean>
get() = _encounteredConnectionError
private val _encounteredConnectionError = mutableStateOf(false)
init {
// Watch for config changes
viewModelScope.launch {
@@ -56,6 +60,12 @@ class ConversationListViewModel @Inject constructor(
}
}
}
viewModelScope.launch {
chatRepository.errorEncounteredChannel.collect {
_encounteredConnectionError.value = true
}
}
}
suspend fun refresh() {

View File

@@ -24,7 +24,7 @@ fun NoContentView(
@DrawableRes icon: Int,
text: String,
onSettings: () -> Unit,
modifier: Modifier,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -20,6 +20,8 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.net.URL
interface APIClient {
val isConfigured: Boolean
fun getAPIInterface(): APIInterface
fun getWebSocketClient(
serverPath: String,
@@ -99,11 +101,12 @@ class APIClientFactory {
companion object {
fun createClient(serverString: String?, authentication: Authentication?): APIClient {
if (serverString == null || authentication == null) {
return InvalidConfigurationAPIClient()
return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.NOT_CONFIGURED)
}
// Try to parse server string
val serverURL = HttpUrl.parse(serverString) ?: return InvalidConfigurationAPIClient()
val serverURL = HttpUrl.parse(serverString)
?: return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.INVALID_HOST_URL)
return RetrofitAPIClient(serverURL.url(), authentication)
}
@@ -111,10 +114,23 @@ class APIClientFactory {
}
// TODO: Is this a dumb idea?
class InvalidConfigurationAPIClient: APIClient {
private class InvalidConfigurationAPIInterface: APIInterface {
class InvalidConfigurationAPIClient(val issue: Issue): APIClient {
enum class Issue {
NOT_CONFIGURED,
INVALID_CONFIGURATION,
INVALID_HOST_URL,
}
class NotConfiguredError: Throwable(message = "Not configured.")
class InvalidConfigurationError(submessage: String): Throwable(message = "Invalid configuration: $submessage")
private class InvalidConfigurationAPIInterface(val issue: Issue): APIInterface {
private fun throwError(): Nothing {
throw Error("Invalid Configuration.")
when (issue) {
Issue.NOT_CONFIGURED -> throw NotConfiguredError()
Issue.INVALID_CONFIGURATION -> throw InvalidConfigurationError("Unknown.")
Issue.INVALID_HOST_URL -> throw InvalidConfigurationError("Invalid host URL.")
}
}
override suspend fun getVersion(): ResponseBody = throwError()
@@ -124,16 +140,14 @@ class InvalidConfigurationAPIClient: APIClient {
override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody = throwError()
override suspend fun uploadAttachment(filename: String, body: RequestBody): retrofit2.Response<UploadAttachmentResponse> = throwError()
override suspend fun authenticate(request: AuthenticationRequest): retrofit2.Response<AuthenticationResponse> = throwError()
override suspend fun getMessages(
conversationGUID: String,
limit: Int?,
beforeMessageGUID: GUID?,
afterMessageGUID: GUID?
): retrofit2.Response<List<Message>> = throwError()
override suspend fun getMessages(conversationGUID: String, limit: Int?, beforeMessageGUID: GUID?, afterMessageGUID: GUID?): retrofit2.Response<List<Message>> = throwError()
}
override val isConfigured: Boolean
get() { return issue != Issue.NOT_CONFIGURED }
override fun getAPIInterface(): APIInterface {
return InvalidConfigurationAPIInterface()
return InvalidConfigurationAPIInterface(issue)
}
override fun getWebSocketClient(
@@ -162,6 +176,9 @@ class RetrofitAPIClient(
.addConverterFactory(GsonConverterFactory.create())
.build()
override val isConfigured: Boolean
get() = true
override fun getAPIInterface(): APIInterface {
return retrofit.create(APIInterface::class.java)
}

View File

@@ -28,16 +28,12 @@ import java.io.InputStream
import java.util.Date
import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue
import kotlin.Boolean
import kotlin.Int
import kotlin.String
import kotlin.let
const val REPO_LOG: String = "ChatRepository"
const val CONVERSATION_MESSAGE_SYNC_COUNT = 10
class ChatRepository(
apiClient: APIClient,
private var apiClient: APIClient,
private val database: CachedChatDatabase,
) {
sealed class Error {
@@ -48,6 +44,11 @@ class ChatRepository(
override val title: String = "Connection Error"
override val description: String = message ?: "???"
}
data class AttachmentError(val message: String): Error() {
override val title: String = "Attachment Error"
override val description: String = message
}
}
// All (Cached) Conversations
@@ -71,6 +72,10 @@ class ChatRepository(
val errorEncounteredChannel: SharedFlow<Error>
get() = _errorEncounteredChannel.asSharedFlow()
val isConfigured: Boolean
get() = apiClient.isConfigured
// New messages for a particular conversation
fun messagesChanged(conversation: Conversation): Flow<List<Message>> =
database.messagesChanged(conversation)
@@ -99,6 +104,7 @@ class ChatRepository(
private var updateWatchScope: CoroutineScope? = null
fun updateAPIClient(client: APIClient) {
this.apiClient = client
this.apiInterface = client.getAPIInterface()
this.updateMonitor = UpdateMonitor(client)
@@ -160,7 +166,7 @@ class ChatRepository(
return database.fetchMessages(conversation)
}
suspend fun synchronize() = try {
suspend fun synchronize() = withErrorChannelHandling {
Log.d(REPO_LOG, "Synchronizing conversations")
// Sync conversations
@@ -173,26 +179,17 @@ class ChatRepository(
for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
synchronizeConversation(conversation)
}
} catch (e: java.lang.Exception) {
_errorEncounteredChannel.emit(Error.ConnectionError(e.message))
} catch (e: java.lang.Error) {
_errorEncounteredChannel.emit(Error.ConnectionError(e.message))
}
suspend fun synchronizeConversation(conversation: Conversation, limit: Int = 15) = try {
suspend fun synchronizeConversation(conversation: Conversation, limit: Int = 15) = withErrorChannelHandling {
// TODO: Should only fetch messages after the last GUID we know about.
// But keep in mind that outgoing message GUIDs are fake...
val messages = fetchMessages(conversation, limit = limit)
database.writeMessages(messages, conversation)
} catch (e: java.lang.Exception) {
_errorEncounteredChannel.emit(Error.ConnectionError(e.message))
}
suspend fun markConversationAsRead(conversation: Conversation) = try {
suspend fun markConversationAsRead(conversation: Conversation) = withErrorChannelHandling(silent = true) {
apiInterface.markConversation(conversation.guid)
} catch (e: java.lang.Exception) {
// Don't report via the channel, but log it.
Log.e(REPO_LOG, "Error marking conversation as read: ${e.message}")
}
suspend fun fetchAttachmentDataSource(guid: String, preview: Boolean): BufferedSource {
@@ -217,6 +214,18 @@ class ChatRepository(
// - private
private suspend fun withErrorChannelHandling(silent: Boolean = false, body: suspend () -> Unit) {
try {
body()
} catch (e: InvalidConfigurationAPIClient.NotConfiguredError) {
// Not configured yet: ignore.
} catch (e: java.lang.Exception) {
if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
} catch (e: java.lang.Error) {
if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
}
}
private suspend fun fetchConversations(): List<Conversation> {
return apiInterface.getConversations().bodyOnSuccessOrThrow()
}
@@ -279,6 +288,7 @@ class ChatRepository(
}
} catch (e: java.lang.Exception) {
Log.e(REPO_LOG, "Error uploading attachment (${e.message}). Dropping...")
_errorEncounteredChannel.emit(Error.AttachmentError("Upload error: ${e.message}"))
}
try {

View File

@@ -40,6 +40,11 @@ class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
private val _messageAdded: MutableSharedFlow<Message> = MutableSharedFlow()
fun beginMonitoringUpdates() {
if (!client.isConfigured) {
Log.e(UPMON_LOG, "Closing websocket connection because client is not configured.")
return
}
Log.d(UPMON_LOG, "Opening websocket connection")
try {
this.webSocket = client.getWebSocketClient(