docs: Revamp README and add APK naming convention This commit introduces a complete overhaul of the `README.md` to improve project presentation. Key changes include: * Adds a new header with the app icon and a Google Play badge. * Updates the feature graphic and includes a note distinguishing the OSS version from the Play Store version. * Refines the "Open Source Libraries" section to be more structured. * Moves the main preview image into the `docs` directory. Additionally, the `app/build.gradle.kts` file is updated to configure a standardized naming convention for output APK files, including flavor, version, and build type. * refactor copyright headers and fix README icon alignment This commit introduces two main changes across the codebase: The `spotless/copyright.kt` template have been updated for consistency: * The author name is simplified from "Episteme Authors" to "Episteme". * The contact email prefix is shortened from "Electronic mail:" to "mail:". Second, the `README.md` file is adjusted to correct the vertical alignment of the app icon with the project title.
293 lines
No EOL
10 KiB
Kotlin
293 lines
No EOL
10 KiB
Kotlin
/*
|
|
* Episteme Reader - A native Android document reader.
|
|
* Copyright (C) 2026 Episteme
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*
|
|
* mail: epistemereader@gmail.com
|
|
*/
|
|
package com.aryan.reader.feedback
|
|
|
|
import android.app.Application
|
|
import android.content.Context
|
|
import android.net.Uri
|
|
import timber.log.Timber
|
|
import androidx.lifecycle.AndroidViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import com.aryan.reader.AuthRepository
|
|
import com.aryan.reader.data.FeedbackMessage
|
|
import com.aryan.reader.data.FeedbackRepository
|
|
import com.aryan.reader.data.FeedbackThread
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.flow.update
|
|
import kotlinx.coroutines.launch
|
|
import java.util.Date
|
|
|
|
data class FeedbackUiState(
|
|
val threads: List<FeedbackThread> = emptyList(),
|
|
val currentMessages: List<FeedbackMessage> = emptyList(),
|
|
val pendingMessages: List<FeedbackMessage> = emptyList(),
|
|
val selectedThreadId: String? = null,
|
|
val isLoading: Boolean = false,
|
|
val errorMessage: String? = null,
|
|
val isCreatingTicket: Boolean = false,
|
|
val newTicketMessage: String = "",
|
|
val newTicketCategory: String = "Bug Report",
|
|
val newTicketAttachments: List<Uri> = emptyList(),
|
|
val chatInputMessage: String = "",
|
|
val chatInputAttachments: List<Uri> = emptyList()
|
|
)
|
|
|
|
class FeedbackViewModel(application: Application) : AndroidViewModel(application) {
|
|
|
|
private val repository = FeedbackRepository(application)
|
|
private val authRepository = AuthRepository(application)
|
|
private val _uiState = MutableStateFlow(FeedbackUiState())
|
|
val uiState = _uiState.asStateFlow()
|
|
|
|
private var threadsListener: Any? = null
|
|
private var messagesListener: Any? = null
|
|
private val currentUser = authRepository.getSignedInUser()
|
|
|
|
init {
|
|
if (currentUser != null) {
|
|
startListeningToThreads(currentUser.uid)
|
|
} else {
|
|
_uiState.update { it.copy(errorMessage = "You must be signed in to use feedback.") }
|
|
}
|
|
}
|
|
|
|
private fun startListeningToThreads(userId: String) {
|
|
_uiState.update { it.copy(isLoading = true) }
|
|
threadsListener = repository.listenToFeedbackThreads(userId) { threads ->
|
|
_uiState.update { it.copy(threads = threads, isLoading = false) }
|
|
}
|
|
}
|
|
|
|
fun onThreadSelected(threadId: String) {
|
|
Timber.d("VM: onThreadSelected $threadId")
|
|
repository.removeListener(messagesListener)
|
|
|
|
_uiState.update {
|
|
it.copy(
|
|
selectedThreadId = threadId,
|
|
isLoading = true,
|
|
currentMessages = emptyList(),
|
|
pendingMessages = emptyList(),
|
|
chatInputMessage = "",
|
|
chatInputAttachments = emptyList()
|
|
)
|
|
}
|
|
|
|
viewModelScope.launch {
|
|
repository.markThreadAsRead(threadId)
|
|
}
|
|
|
|
messagesListener = repository.listenToMessages(threadId) { messages ->
|
|
Timber.d("VM: Listener update received. ${messages.size} messages.")
|
|
viewModelScope.launch {
|
|
repository.markThreadAsRead(threadId)
|
|
}
|
|
|
|
_uiState.update { state ->
|
|
val realIds = messages.map { it.id }.toSet()
|
|
val remainingPending = state.pendingMessages.filter { it.id !in realIds }
|
|
|
|
if (remainingPending.size != state.pendingMessages.size) {
|
|
Timber.d("VM: Reconciliation - Removed ${state.pendingMessages.size - remainingPending.size} pending messages.")
|
|
}
|
|
|
|
state.copy(
|
|
currentMessages = messages,
|
|
pendingMessages = remainingPending,
|
|
isLoading = false
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun onBackToThreadList() {
|
|
Timber.d("VM: Back to thread list")
|
|
repository.removeListener(messagesListener)
|
|
messagesListener = null
|
|
_uiState.update { it.copy(selectedThreadId = null, currentMessages = emptyList(), pendingMessages = emptyList()) }
|
|
}
|
|
|
|
fun onStartCreateTicket() {
|
|
if (currentUser == null) {
|
|
_uiState.update { it.copy(errorMessage = "You must be signed in to submit feedback.") }
|
|
return
|
|
}
|
|
_uiState.update { it.copy(isCreatingTicket = true) }
|
|
}
|
|
|
|
fun onCancelCreateTicket() {
|
|
_uiState.update { it.copy(isCreatingTicket = false, newTicketMessage = "", newTicketAttachments = emptyList()) }
|
|
}
|
|
|
|
fun onNewTicketMessageChange(text: String) {
|
|
_uiState.update { it.copy(newTicketMessage = text) }
|
|
}
|
|
|
|
fun onNewTicketCategoryChange(category: String) {
|
|
_uiState.update { it.copy(newTicketCategory = category) }
|
|
}
|
|
|
|
fun onNewTicketImagesSelected(uris: List<Uri>) {
|
|
val current = _uiState.value.newTicketAttachments
|
|
if (current.size + uris.size > 3) {
|
|
_uiState.update { it.copy(errorMessage = "Max 3 images allowed for tickets.") }
|
|
return
|
|
}
|
|
validateAndAddImages(uris) { validUris ->
|
|
_uiState.update { it.copy(newTicketAttachments = it.newTicketAttachments + validUris) }
|
|
}
|
|
}
|
|
|
|
fun onRemoveNewTicketImage(uri: Uri) {
|
|
_uiState.update { it.copy(newTicketAttachments = it.newTicketAttachments - uri) }
|
|
}
|
|
|
|
fun onChatInputChange(text: String) {
|
|
_uiState.update { it.copy(chatInputMessage = text) }
|
|
}
|
|
|
|
fun onChatImagesSelected(uris: List<Uri>) {
|
|
val current = _uiState.value.chatInputAttachments
|
|
if (current.size + uris.size > 5) {
|
|
_uiState.update { it.copy(errorMessage = "Max 5 images allowed per message.") }
|
|
return
|
|
}
|
|
validateAndAddImages(uris) { validUris ->
|
|
_uiState.update { it.copy(chatInputAttachments = it.chatInputAttachments + validUris) }
|
|
}
|
|
}
|
|
|
|
fun onRemoveChatImage(uri: Uri) {
|
|
_uiState.update { it.copy(chatInputAttachments = it.chatInputAttachments - uri) }
|
|
}
|
|
|
|
private fun validateAndAddImages(uris: List<Uri>, onValid: (List<Uri>) -> Unit) {
|
|
val context = getApplication<Application>()
|
|
val maxFileSize = 5 * 1024 * 1024 // 5 MB
|
|
val validUris = mutableListOf<Uri>()
|
|
for (uri in uris) {
|
|
val fileSize = getFileSize(context, uri)
|
|
if (fileSize > maxFileSize) {
|
|
_uiState.update { it.copy(errorMessage = "One or more images exceed the 5MB limit.") }
|
|
return
|
|
}
|
|
validUris.add(uri)
|
|
}
|
|
onValid(validUris)
|
|
}
|
|
|
|
private fun getFileSize(context: Context, uri: Uri): Long {
|
|
return try {
|
|
context.contentResolver.openFileDescriptor(uri, "r")?.use { it.statSize } ?: 0L
|
|
} catch (_: Exception) { 0L }
|
|
}
|
|
|
|
fun onSubmitTicket() {
|
|
val state = _uiState.value
|
|
if (state.newTicketMessage.isBlank()) return
|
|
val user = currentUser ?: return
|
|
|
|
onCancelCreateTicket()
|
|
|
|
viewModelScope.launch {
|
|
_uiState.update { it.copy(isLoading = true) }
|
|
try {
|
|
val threadId = repository.createThread(
|
|
userId = user.uid,
|
|
category = state.newTicketCategory,
|
|
message = state.newTicketMessage,
|
|
attachmentUris = state.newTicketAttachments
|
|
)
|
|
onThreadSelected(threadId)
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "ViewModel: Error submitting ticket")
|
|
_uiState.update { it.copy(errorMessage = "Failed to create ticket: ${e.message}") }
|
|
} finally {
|
|
_uiState.update { it.copy(isLoading = false) }
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
fun onSendMessage() {
|
|
val state = _uiState.value
|
|
val threadId = state.selectedThreadId ?: return
|
|
val textToSend = state.chatInputMessage.trim()
|
|
val attachmentsToSend = state.chatInputAttachments
|
|
|
|
if (textToSend.isBlank() && attachmentsToSend.isEmpty()) return
|
|
val user = currentUser ?: return
|
|
|
|
val messageId = repository.generateMessageId()
|
|
Timber.d("VM: Sending message. Generated ID: $messageId")
|
|
|
|
// Updated reference to id
|
|
val pendingMessage = FeedbackMessage(
|
|
id = messageId,
|
|
text = textToSend,
|
|
sender = "user",
|
|
timestamp = Date(),
|
|
attachments = attachmentsToSend.map { it.toString() }
|
|
)
|
|
|
|
_uiState.update {
|
|
it.copy(
|
|
chatInputMessage = "",
|
|
chatInputAttachments = emptyList(),
|
|
pendingMessages = it.pendingMessages + pendingMessage
|
|
)
|
|
}
|
|
|
|
viewModelScope.launch {
|
|
try {
|
|
repository.addMessage(
|
|
threadId = threadId,
|
|
messageId = messageId,
|
|
uid = user.uid,
|
|
message = textToSend,
|
|
sender = "user",
|
|
attachmentUris = attachmentsToSend
|
|
)
|
|
Timber.d("VM: addMessage returned success for $messageId")
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "ViewModel: Error sending message")
|
|
_uiState.update {
|
|
it.copy(
|
|
errorMessage = "Failed to send: ${e.message}",
|
|
// Updated reference to id
|
|
pendingMessages = it.pendingMessages.filterNot { msg -> msg.id == messageId },
|
|
chatInputMessage = textToSend
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun clearError() {
|
|
_uiState.update { it.copy(errorMessage = null) }
|
|
}
|
|
|
|
override fun onCleared() {
|
|
super.onCleared()
|
|
repository.removeListener(messagesListener)
|
|
repository.removeListener(threadsListener)
|
|
}
|
|
} |