From 82890d85badfb59d66fa265facafa154fbb2fd55 Mon Sep 17 00:00:00 2001 From: mrfluffy Date: Tue, 3 Mar 2026 11:48:15 +0000 Subject: [PATCH] gif and better reactions --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 9 + .../data/local/PreferencesManager.kt | 11 + .../fluffytrix/data/model/TenorResponse.kt | 37 ++ .../data/repository/GifRepository.kt | 48 ++ .../ui/navigation/FluffytrixNavigation.kt | 6 +- .../fluffytrix/ui/screens/main/MainScreen.kt | 20 +- .../ui/screens/main/MainViewModel.kt | 54 +- .../main/components/MessageTimeline.kt | 495 ++++++++++++++---- .../ui/screens/settings/SettingsScreen.kt | 122 +++++ app/src/main/res/xml/file_provider_paths.xml | 4 + 11 files changed, 689 insertions(+), 120 deletions(-) create mode 100644 app/src/main/java/com/example/fluffytrix/data/model/TenorResponse.kt create mode 100644 app/src/main/java/com/example/fluffytrix/data/repository/GifRepository.kt create mode 100644 app/src/main/res/xml/file_provider_paths.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9092868..d9bc8ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,7 @@ android { versionName = "1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "TENOR_API_KEY", "\"AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCDY\"") } buildTypes { @@ -41,7 +42,9 @@ android { } buildFeatures { compose = true + buildConfig = true } + packaging { dex { useLegacyPackaging = true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d188c5d..15dc74c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,15 @@ + + + diff --git a/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt b/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt index 604b108..c4472a3 100644 --- a/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt +++ b/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt @@ -31,6 +31,7 @@ class PreferencesManager(private val context: Context) { private val KEY_HIDE_SPACES_WHEN_CLOSED = booleanPreferencesKey("hide_spaces_when_closed") private val KEY_THREAD_NAMES = stringPreferencesKey("thread_names") private val KEY_HIDDEN_THREADS = stringPreferencesKey("hidden_threads") + private val KEY_TENOR_API_KEY = stringPreferencesKey("tenor_api_key") } val isLoggedIn: Flow = context.dataStore.data.map { prefs -> @@ -125,6 +126,16 @@ class PreferencesManager(private val context: Context) { } } + val tenorApiKey: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_TENOR_API_KEY] ?: "" + } + + suspend fun setTenorApiKey(key: String) { + context.dataStore.edit { prefs -> + prefs[KEY_TENOR_API_KEY] = key + } + } + // Thread names: key = "roomId:threadRootEventId", value = custom name val threadNames: Flow> = context.dataStore.data.map { prefs -> val raw = prefs[KEY_THREAD_NAMES] ?: return@map emptyMap() diff --git a/app/src/main/java/com/example/fluffytrix/data/model/TenorResponse.kt b/app/src/main/java/com/example/fluffytrix/data/model/TenorResponse.kt new file mode 100644 index 0000000..428ba5f --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/data/model/TenorResponse.kt @@ -0,0 +1,37 @@ +package com.example.fluffytrix.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GiphyResponse( + val data: List = emptyList(), +) + +@Serializable +data class GiphyResult( + val id: String, + val title: String = "", + val images: GiphyImages = GiphyImages(), +) { + val previewUrl: String get() = images.fixedWidthDownsampled.url + .ifBlank { images.fixedHeight.url } + val fullUrl: String get() = images.original.url + .ifBlank { images.fixedHeight.url } + val previewWidth: Int get() = images.fixedWidthDownsampled.width.toIntOrNull() ?: 200 + val previewHeight: Int get() = images.fixedWidthDownsampled.height.toIntOrNull() ?: 200 +} + +@Serializable +data class GiphyImages( + @SerialName("fixed_height") val fixedHeight: GiphyImageEntry = GiphyImageEntry(), + @SerialName("fixed_width_downsampled") val fixedWidthDownsampled: GiphyImageEntry = GiphyImageEntry(), + val original: GiphyImageEntry = GiphyImageEntry(), +) + +@Serializable +data class GiphyImageEntry( + val url: String = "", + val width: String = "200", + val height: String = "200", +) diff --git a/app/src/main/java/com/example/fluffytrix/data/repository/GifRepository.kt b/app/src/main/java/com/example/fluffytrix/data/repository/GifRepository.kt new file mode 100644 index 0000000..6c63767 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/data/repository/GifRepository.kt @@ -0,0 +1,48 @@ +package com.example.fluffytrix.data.repository + +import android.util.Log +import com.example.fluffytrix.data.model.GiphyResponse +import com.example.fluffytrix.data.model.GiphyResult +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request + +class GifRepository { + private val client = OkHttpClient() + private val json = Json { ignoreUnknownKeys = true } + private val baseUrl = "https://api.giphy.com/v1/gifs" + + fun trending(apiKey: String, limit: Int = 24): List { + val url = "$baseUrl/trending?api_key=$apiKey&limit=$limit&rating=g" + return fetch(url) + } + + fun search(apiKey: String, query: String, limit: Int = 24): List { + val encoded = java.net.URLEncoder.encode(query, "UTF-8") + val url = "$baseUrl/search?api_key=$apiKey&q=$encoded&limit=$limit&rating=g" + return fetch(url) + } + + private fun fetch(url: String): List { + Log.d("GifRepository", "Fetching: $url") + return try { + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + Log.d("GifRepository", "Response code: ${response.code}") + val body = response.body?.string() ?: run { + Log.w("GifRepository", "Empty body") + return emptyList() + } + if (!response.isSuccessful) { + Log.e("GifRepository", "Error response: $body") + return emptyList() + } + val results = json.decodeFromString(body).data + Log.d("GifRepository", "Parsed ${results.size} results") + results + } catch (e: Exception) { + Log.e("GifRepository", "Fetch failed", e) + emptyList() + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt b/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt index 9de3d02..c52e2f6 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt @@ -151,9 +151,6 @@ fun FluffytrixNavigation() { onSettingsClick = { navController.navigate(Screen.Settings.route) }, - onEmojiPackManagement = { roomId -> - navController.navigate(Screen.EmojiPackManagement.route(roomId)) - }, ) } composable( @@ -174,6 +171,9 @@ fun FluffytrixNavigation() { popUpTo(0) { inclusive = true } } }, + onEmojiPackManagement = { + navController.navigate(Screen.EmojiPackManagement.route()) + }, ) } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index eed5801..9902310 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,6 +19,8 @@ import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.example.fluffytrix.data.local.PreferencesManager @@ -31,7 +35,6 @@ import org.koin.compose.koinInject fun MainScreen( onLogout: () -> Unit, onSettingsClick: () -> Unit = {}, - onEmojiPackManagement: (String?) -> Unit = {}, viewModel: MainViewModel = koinViewModel(), ) { val spaces by viewModel.spaces.collectAsStateWithLifecycle() @@ -59,9 +62,16 @@ fun MainScreen( val preferencesManager: PreferencesManager = koinInject() val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false) - // Back button: close thread first, then open channel list - BackHandler(enabled = selectedThread != null || (selectedChannel != null && !showChannelList)) { - if (selectedThread != null) { + val keyboardController = LocalSoftwareKeyboardController.current + val imeInsets = WindowInsets.ime + val density = LocalDensity.current + val isKeyboardVisible = imeInsets.getBottom(density) > 0 + + // Back button: dismiss keyboard first, then close thread, then open channel list + BackHandler(enabled = isKeyboardVisible || selectedThread != null || (selectedChannel != null && !showChannelList)) { + if (isKeyboardVisible) { + keyboardController?.hide() + } else if (selectedThread != null) { viewModel.closeThread() } else { viewModel.toggleChannelList() @@ -110,6 +120,7 @@ fun MainScreen( onToggleMemberList = { viewModel.toggleMemberList() }, onSendMessage = { viewModel.sendMessage(it) }, onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) }, + onSendGif = { url -> viewModel.sendGif(url) }, onLoadMore = { viewModel.loadMoreMessages() }, unreadMarkerIndex = unreadMarkerIndex, modifier = Modifier.weight(1f), @@ -145,7 +156,6 @@ fun MainScreen( onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) }, onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) }, emojiPacks = emojiPacks, - onOpenEmojiPackManagement = { onEmojiPackManagement(selectedChannel) }, ) AnimatedVisibility(visible = showMemberList) { diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index 251e438..f3248ed 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -68,9 +68,9 @@ data class InlineEmoji(val shortcode: String, val mxcUrl: String, val resolvedUr sealed interface MessageContent { data class Text(val body: String, val urls: List = emptyList(), val inlineEmojis: List = emptyList()) : MessageContent - data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent - data class Gif(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent - data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent + data class Image(val body: String, val url: String, val sourceJson: String? = null, val mimeType: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent + data class Gif(val body: String, val url: String, val sourceJson: String? = null, val mimeType: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent + data class Video(val body: String, val url: String? = null, val sourceJson: String? = null, val mimeType: String? = null, val thumbnailUrl: String? = null, val thumbnailSourceJson: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent data class File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent } @@ -833,16 +833,21 @@ class MainViewModel( val c = msgType.content val mxcUrl = c.source.url() val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl + val sourceJson = try { c.source.toJson() } catch (_: Exception) { null } val info = c.info val isGif = info?.mimetype == "image/gif" || info?.isAnimated == true if (isGif) MessageContent.Gif( body = c.filename, url = url, + sourceJson = sourceJson, + mimeType = info?.mimetype ?: "image/gif", width = info?.width?.toInt(), height = info?.height?.toInt(), ) else MessageContent.Image( body = c.filename, url = url, + sourceJson = sourceJson, + mimeType = info?.mimetype ?: "image/*", width = info?.width?.toInt(), height = info?.height?.toInt(), ) @@ -851,6 +856,7 @@ class MainViewModel( val c = msgType.content val mxcUrl = c.source.url() val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl + val sourceJson = try { c.source.toJson() } catch (_: Exception) { null } val info = c.info // Detect Discord bridge GIFs: fi.mau.gif in raw event, or tenor/giphy body URL val isGifVideo = (rawJson != null && rawJson.contains("\"fi.mau.gif\"")) || @@ -860,16 +866,22 @@ class MainViewModel( MessageContent.Gif( body = c.filename, url = url, + sourceJson = sourceJson, + mimeType = info?.mimetype ?: "video/mp4", width = info?.width?.toInt(), height = info?.height?.toInt(), ) } else { val thumbMxc = info?.thumbnailSource?.url() val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url + val thumbnailSourceJson = try { info?.thumbnailSource?.toJson() } catch (_: Exception) { null } MessageContent.Video( body = c.filename, url = url, + sourceJson = sourceJson, + mimeType = info?.mimetype ?: "video/*", thumbnailUrl = thumbnailUrl, + thumbnailSourceJson = thumbnailSourceJson, width = info?.width?.toInt(), height = info?.height?.toInt(), ) @@ -1059,6 +1071,42 @@ class MainViewModel( } } + fun sendGif(url: String) { + val timeline = activeTimeline ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + android.util.Log.d("SendGif", "Downloading $url") + val client = okhttp3.OkHttpClient() + val response = client.newCall(okhttp3.Request.Builder().url(url).build()).execute() + android.util.Log.d("SendGif", "Response: ${response.code}") + val bytes = response.body?.bytes() ?: run { + android.util.Log.e("SendGif", "Empty body") + return@launch + } + android.util.Log.d("SendGif", "Downloaded ${bytes.size} bytes, sending…") + val params = UploadParameters( + source = UploadSource.Data(bytes, "giphy.gif"), + caption = null, + formattedCaption = null, + mentions = null, + inReplyTo = null, + ) + timeline.sendFile( + params = params, + fileInfo = org.matrix.rustcomponents.sdk.FileInfo( + mimetype = "image/gif", + size = bytes.size.toULong(), + thumbnailInfo = null, + thumbnailSource = null, + ), + ) + android.util.Log.d("SendGif", "Sent!") + } catch (e: Exception) { + android.util.Log.e("SendGif", "Failed", e) + } + } + } + fun selectHome() { if (_selectedSpace.value == null) { _showChannelList.value = !_showChannelList.value diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index 3658e9c..9c8b146 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -74,13 +74,21 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.emoji2.emojipicker.EmojiPickerView import androidx.emoji2.emojipicker.EmojiViewItem import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab @@ -89,12 +97,17 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import kotlinx.coroutines.launch import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -107,7 +120,11 @@ import com.example.fluffytrix.data.repository.AuthRepository import org.koin.compose.koinInject import coil3.compose.AsyncImage import com.example.fluffytrix.data.MxcUrlHelper +import androidx.compose.runtime.produceState +import org.matrix.rustcomponents.sdk.MediaSource import com.example.fluffytrix.data.model.EmojiPack +import com.example.fluffytrix.data.model.GiphyResult +import com.example.fluffytrix.data.repository.GifRepository import com.example.fluffytrix.ui.screens.main.InlineEmoji import com.example.fluffytrix.ui.screens.main.MessageContent import com.example.fluffytrix.ui.screens.main.MessageItem @@ -155,6 +172,7 @@ fun MessageTimeline( onToggleMemberList: () -> Unit, onSendMessage: (String) -> Unit, onSendFiles: (List, String?) -> Unit, + onSendGif: (String) -> Unit = {}, onLoadMore: () -> Unit = {}, unreadMarkerIndex: Int = -1, modifier: Modifier = Modifier, @@ -178,7 +196,6 @@ fun MessageTimeline( onSendReaction: (String, String) -> Unit = { _, _ -> }, onSendThreadReaction: (String, String) -> Unit = { _, _ -> }, emojiPacks: List = emptyList(), - onOpenEmojiPackManagement: () -> Unit = {}, ) { var fullscreenImageUrl by remember { mutableStateOf(null) } var fullscreenVideoUrl by remember { mutableStateOf(null) } @@ -251,7 +268,7 @@ fun MessageTimeline( if (selectedThread != null) { ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread) } else { - TopBar(channelName ?: selectedChannel, onToggleMemberList, onOpenEmojiPackManagement) + TopBar(channelName ?: selectedChannel, onToggleMemberList) } HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) } @@ -422,6 +439,7 @@ fun MessageTimeline( editingMessage = editingMessage, onSendMessage = activeSend, onSendFiles = onSendFiles, + onSendGif = onSendGif, onSendReply = { body, eventId -> if (selectedThread != null) onSendThreadReply(body, eventId) else onSendReply(body, eventId) @@ -438,7 +456,7 @@ fun MessageTimeline( } @Composable -private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPackManagement: () -> Unit = {}) { +private fun TopBar(name: String, onToggleMemberList: () -> Unit) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -452,9 +470,6 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPack color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f), ) - IconButton(onClick = onOpenEmojiPackManagement) { - Icon(Icons.Default.EmojiEmotions, "Emoji packs", tint = MaterialTheme.colorScheme.onSurfaceVariant) - } IconButton(onClick = onToggleMemberList) { Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -488,6 +503,36 @@ private fun ReactionRow(eventId: String, reactions: Map>) { val currentUserId = LocalCurrentUserId.current val authRepository: AuthRepository = koinInject() val baseUrl = remember { try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } catch (_: Exception) { "" } } + var reactionDetailEmoji by remember { mutableStateOf(null) } + var reactionDetailSenders by remember { mutableStateOf>(emptyList()) } + + if (reactionDetailEmoji != null) { + AlertDialog( + onDismissRequest = { reactionDetailEmoji = null }, + title = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (reactionDetailEmoji!!.startsWith("mxc://")) { + val resolvedUrl = remember(reactionDetailEmoji) { MxcUrlHelper.mxcToDownloadUrl(baseUrl, reactionDetailEmoji!!) ?: reactionDetailEmoji!! } + AsyncImage(model = resolvedUrl, contentDescription = null, modifier = Modifier.size(24.dp)) + } else { + Text(reactionDetailEmoji!!, fontSize = 20.sp) + } + Text("Reacted by", style = MaterialTheme.typography.titleMedium) + } + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + reactionDetailSenders.forEach { sender -> + Text(sender, style = MaterialTheme.typography.bodyMedium) + } + } + }, + confirmButton = { + TextButton(onClick = { reactionDetailEmoji = null }) { Text("Close") } + }, + ) + } + FlowRow( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -499,7 +544,13 @@ private fun ReactionRow(eventId: String, reactions: Map>) { shape = RoundedCornerShape(12.dp), color = if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.clickable { onReact(eventId, emoji) }, + modifier = Modifier.combinedClickable( + onClick = { onReact(eventId, emoji) }, + onLongClick = { + reactionDetailEmoji = emoji + reactionDetailSenders = senders + }, + ), ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), @@ -755,20 +806,44 @@ private fun InlineEmojiText(content: MessageContent.Text) { } } +private suspend fun resolveMediaUrl( + authRepository: AuthRepository, + sourceJson: String, + mimeType: String, + filename: String?, + fallbackUrl: String?, +): String? = try { + val client = authRepository.getClient() ?: return fallbackUrl + val source = MediaSource.fromJson(sourceJson) + val handle = client.getMediaFile(source, filename, mimeType, useCache = true, tempDir = null) + "file://${handle.path()}" +} catch (_: Exception) { + fallbackUrl +} + @Composable private fun ImageContent(content: MessageContent.Image) { val onViewImage = LocalImageViewer.current + val authRepository: AuthRepository = koinInject() val aspectRatio = if (content.width != null && content.height != null && content.height > 0) content.width.toFloat() / content.height.toFloat() else null + val resolvedUrl by produceState(null, content.sourceJson, content.url) { + value = if (content.sourceJson != null) { + resolveMediaUrl(authRepository, content.sourceJson, content.mimeType ?: "image/*", content.body, content.url) + } else { + content.url + } + } + AsyncImage( - model = content.url, + model = resolvedUrl, contentDescription = content.body, modifier = Modifier .let { if (aspectRatio != null) it.width((300.dp * aspectRatio).coerceAtMost(400.dp)) else it.fillMaxWidth(0.6f) } .height(300.dp) .clip(RoundedCornerShape(8.dp)) - .clickable { onViewImage(content.url) }, + .clickable { resolvedUrl?.let { onViewImage(it) } }, contentScale = ContentScale.Fit, ) } @@ -781,46 +856,95 @@ private fun GifContent(content: MessageContent.Gif) { val aspectRatio = if (content.width != null && content.height != null && content.height > 0) content.width.toFloat() / content.height.toFloat() else 16f / 9f - val exoPlayer = remember(content.url) { - val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null } - val dataSourceFactory = DefaultHttpDataSource.Factory().apply { - if (token != null) { - setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) - } - } - val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(Uri.parse(content.url))) - ExoPlayer.Builder(context).build().apply { - setMediaSource(mediaSource) - prepare() - playWhenReady = true - repeatMode = ExoPlayer.REPEAT_MODE_ALL - volume = 0f + val isNativeGif = content.mimeType == "image/gif" || + content.body.endsWith(".gif", ignoreCase = true) || + content.url.endsWith(".gif", ignoreCase = true) + + val resolvedUrl by produceState(null, content.sourceJson, content.url) { + value = if (content.sourceJson != null) { + resolveMediaUrl(authRepository, content.sourceJson, content.mimeType ?: "video/mp4", content.body, content.url) + } else { + content.url } } - DisposableEffect(content.url) { - onDispose { exoPlayer.release() } - } - - AndroidView( - factory = { ctx -> - PlayerView(ctx).apply { - player = exoPlayer - useController = false - setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER) + if (isNativeGif) { + // Real GIF — use Coil (coil-gif handles animated playback) + AsyncImage( + model = resolvedUrl, + contentDescription = content.body, + contentScale = ContentScale.Fit, + modifier = Modifier + .width((200.dp * aspectRatio).coerceAtMost(400.dp)) + .height(200.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } else { + // Video GIF (MP4/WebM from bridges) — use ExoPlayer + val exoPlayer = remember(resolvedUrl) { + val url = resolvedUrl ?: return@remember null + val isFileUri = url.startsWith("file://") + val mediaItem = MediaItem.fromUri(Uri.parse(url)) + val mediaSource = if (isFileUri) { + ProgressiveMediaSource.Factory(androidx.media3.datasource.FileDataSource.Factory()) + .createMediaSource(mediaItem) + } else { + val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null } + val dataSourceFactory = DefaultHttpDataSource.Factory().apply { + if (token != null) setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) + } + ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) } - }, - modifier = Modifier - .width((200.dp * aspectRatio).coerceAtMost(400.dp)) - .height(200.dp) - .clip(RoundedCornerShape(8.dp)), - ) + ExoPlayer.Builder(context).build().apply { + setMediaSource(mediaSource) + prepare() + playWhenReady = true + repeatMode = ExoPlayer.REPEAT_MODE_ALL + volume = 0f + } + } + + DisposableEffect(resolvedUrl) { + onDispose { exoPlayer?.release() } + } + + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + useController = false + setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER) + } + }, + update = { view -> view.player = exoPlayer }, + modifier = Modifier + .width((200.dp * aspectRatio).coerceAtMost(400.dp)) + .height(200.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } } @Composable private fun VideoContent(content: MessageContent.Video) { val onPlayVideo = LocalVideoPlayer.current + val authRepository: AuthRepository = koinInject() + + val resolvedVideoUrl by produceState(null, content.sourceJson, content.url) { + value = if (content.sourceJson != null) { + resolveMediaUrl(authRepository, content.sourceJson, content.mimeType ?: "video/*", content.body, content.url) + } else { + content.url + } + } + + val resolvedThumbnailUrl by produceState(null, content.thumbnailSourceJson, content.thumbnailUrl) { + value = if (content.thumbnailSourceJson != null) { + resolveMediaUrl(authRepository, content.thumbnailSourceJson, "image/*", null, content.thumbnailUrl) + } else { + content.thumbnailUrl + } + } + Box( modifier = Modifier .height(200.dp) @@ -830,12 +954,12 @@ private fun VideoContent(content: MessageContent.Video) { mod.width((200.dp * ar).coerceAtMost(400.dp)) } .clip(RoundedCornerShape(8.dp)) - .clickable { content.url?.let { onPlayVideo(it) } }, + .clickable { resolvedVideoUrl?.let { onPlayVideo(it) } }, contentAlignment = Alignment.Center, ) { - if (content.thumbnailUrl != null) { + if (resolvedThumbnailUrl != null) { AsyncImage( - model = content.thumbnailUrl, + model = resolvedThumbnailUrl, contentDescription = content.body, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, @@ -912,14 +1036,17 @@ private fun FullscreenVideoPlayer(url: String, onDismiss: () -> Unit) { val context = LocalContext.current val authRepository: AuthRepository = koinInject() val exoPlayer = remember { - val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null } - val dataSourceFactory = DefaultHttpDataSource.Factory().apply { - if (token != null) { - setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) + val mediaItem = MediaItem.fromUri(Uri.parse(url)) + val mediaSource = if (url.startsWith("file://")) { + ProgressiveMediaSource.Factory(androidx.media3.datasource.FileDataSource.Factory()) + .createMediaSource(mediaItem) + } else { + val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null } + val dataSourceFactory = DefaultHttpDataSource.Factory().apply { + if (token != null) setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) } + ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) } - val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(Uri.parse(url))) ExoPlayer.Builder(context).build().apply { setMediaSource(mediaSource) prepare() @@ -1012,12 +1139,15 @@ private fun MessageInput( channelName: String, onSendMessage: (String) -> Unit, onSendFiles: (List, String?) -> Unit, + onSendGif: (String) -> Unit = {}, replyingTo: MessageItem? = null, editingMessage: MessageItem? = null, onSendReply: (String, String) -> Unit = { _, _ -> }, onEditMessage: (String, String) -> Unit = { _, _ -> }, emojiPacks: List = emptyList(), ) { + val prefsManager: com.example.fluffytrix.data.local.PreferencesManager = koinInject() + val gifApiKey by prefsManager.tenorApiKey.collectAsState(initial = "") var text by remember { mutableStateOf("") } var attachedUris by remember { mutableStateOf(listOf()) } var showEmojiPackPicker by remember { mutableStateOf(false) } @@ -1063,8 +1193,12 @@ private fun MessageInput( modifier = Modifier.weight(1f), ) } - val tabs = emojiPacks.map { it.displayName } + "Unicode" + val inputScope = rememberCoroutineScope() + val inputContext = LocalContext.current var selectedTab by remember { mutableStateOf(0) } + val tabs = if (emojiPacks.isNotEmpty()) listOf("Custom Emojis", "GIFs", "Unicode") else listOf("GIFs", "Unicode") + val customTabIndex = if (emojiPacks.isNotEmpty()) 0 else -1 + val gifTabIndex = if (emojiPacks.isNotEmpty()) 1 else 0 ScrollableTabRow(selectedTabIndex = selectedTab) { tabs.forEachIndexed { index, title -> Tab( @@ -1074,27 +1208,36 @@ private fun MessageInput( ) } } - if (selectedTab < emojiPacks.size) { - CustomEmojiGrid( - pack = emojiPacks[selectedTab], + when { + selectedTab == customTabIndex -> CollapsableCustomEmojiPacks( + packs = emojiPacks, onEmojiSelected = { entry -> text = text + ":${entry.shortcode}:" showEmojiPackPicker = false }, modifier = Modifier.fillMaxWidth().weight(1f), ) - } else { - val currentText by rememberUpdatedState(text) - AndroidView( - factory = { ctx -> EmojiPickerView(ctx) }, - update = { view -> - view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem -> - text = currentText + emojiViewItem.emoji - showEmojiPackPicker = false - } + selectedTab == gifTabIndex -> GifSearchTab( + apiKey = gifApiKey, + onGifSelected = { gif -> + showEmojiPackPicker = false + onSendGif(gif.fullUrl) }, modifier = Modifier.fillMaxWidth().weight(1f), ) + else -> { + val currentText by rememberUpdatedState(text) + AndroidView( + factory = { ctx -> EmojiPickerView(ctx) }, + update = { view -> + view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem -> + text = currentText + emojiViewItem.emoji + showEmojiPackPicker = false + } + }, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } } } } @@ -1194,14 +1337,6 @@ private fun MessageInput( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (emojiPacks.isNotEmpty()) { - IconButton(onClick = { showEmojiPackPicker = true }) { - Icon( - Icons.Default.EmojiEmotions, "Insert emoji", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } TextField( value = text, onValueChange = { text = it }, @@ -1215,6 +1350,14 @@ private fun MessageInput( focusedIndicatorColor = Color.Transparent, ), ) + if (emojiPacks.isNotEmpty()) { + IconButton(onClick = { showEmojiPackPicker = true }) { + Icon( + Icons.Default.EmojiEmotions, "Insert emoji", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } IconButton( onClick = { val trimmed = text.trim() @@ -1300,44 +1443,28 @@ private fun MessageContextMenu( modifier = Modifier.weight(1f), ) } - if (emojiPacks.isNotEmpty()) { - var selectedTab by remember { mutableStateOf(0) } - val tabs = listOf("Unicode") + emojiPacks.map { it.displayName } - ScrollableTabRow(selectedTabIndex = selectedTab) { - tabs.forEachIndexed { index, title -> - Tab( - selected = selectedTab == index, - onClick = { selectedTab = index }, - text = { Text(title, maxLines = 1) }, - ) - } - } - when { - selectedTab == 0 -> AndroidView( - factory = { ctx -> EmojiPickerView(ctx) }, - update = { view -> - view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem -> - currentOnReact(emojiViewItem.emoji) - showEmojiPicker = false - } - }, - modifier = Modifier.fillMaxWidth().weight(1f), + var selectedTab by remember { mutableStateOf(0) } + val tabs = if (emojiPacks.isNotEmpty()) listOf("Custom Emojis", "Unicode") else listOf("Unicode") + val customTabIndex = if (emojiPacks.isNotEmpty()) 0 else -1 + ScrollableTabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title, maxLines = 1) }, ) - else -> { - val pack = emojiPacks[selectedTab - 1] - CustomEmojiGrid( - pack = pack, - onEmojiSelected = { entry -> - currentOnReact(entry.mxcUrl) - showEmojiPicker = false - }, - modifier = Modifier.fillMaxWidth().weight(1f), - ) - } } - } else { - Spacer(modifier = Modifier.height(8.dp)) - AndroidView( + } + when { + selectedTab == customTabIndex -> CollapsableCustomEmojiPacks( + packs = emojiPacks, + onEmojiSelected = { entry -> + currentOnReact(entry.mxcUrl) + showEmojiPicker = false + }, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + else -> AndroidView( factory = { ctx -> EmojiPickerView(ctx) }, update = { view -> view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem -> @@ -1345,9 +1472,7 @@ private fun MessageContextMenu( showEmojiPicker = false } }, - modifier = Modifier - .fillMaxWidth() - .weight(1f), + modifier = Modifier.fillMaxWidth().weight(1f), ) } } @@ -1510,6 +1635,158 @@ private fun EditModeBar(body: String, onDismiss: () -> Unit) { } } +@Composable +private fun GifSearchTab( + apiKey: String, + onGifSelected: (GiphyResult) -> Unit, + modifier: Modifier = Modifier, +) { + val gifRepo = remember { GifRepository() } + var query by remember { mutableStateOf("") } + val results = remember { mutableStateListOf() } + var errorMessage by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + + LaunchedEffect(query, apiKey) { + if (query.isNotBlank()) kotlinx.coroutines.delay(400) + if (apiKey.isBlank()) { + errorMessage = "No GIPHY API key — add one in Settings → Customization." + isLoading = false + return@LaunchedEffect + } + isLoading = true + errorMessage = null + try { + val list = withContext(Dispatchers.IO) { + if (query.isBlank()) gifRepo.trending(apiKey) else gifRepo.search(apiKey, query) + } + results.clear() + results.addAll(list) + } catch (e: Exception) { + errorMessage = e.message ?: "Unknown error" + } finally { + isLoading = false + } + } + + val keyboardController = LocalSoftwareKeyboardController.current + + Column(modifier = modifier) { + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Search GIFs…") }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .clip(RoundedCornerShape(24.dp)), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }), + colors = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ), + ) + when { + isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + androidx.compose.material3.CircularProgressIndicator() + } + errorMessage != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Error: $errorMessage", color = MaterialTheme.colorScheme.error) + } + results.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No GIFs found", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + else -> LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(results, key = { it.id }) { gif -> + val ratio = if (gif.previewHeight > 0) gif.previewWidth.toFloat() / gif.previewHeight else 1f + AsyncImage( + model = gif.previewUrl, + contentDescription = gif.title, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(ratio) + .clip(RoundedCornerShape(4.dp)) + .clickable { onGifSelected(gif) }, + ) + } + } + } + } +} + +@Composable +private fun CollapsableCustomEmojiPacks( + packs: List, + onEmojiSelected: (com.example.fluffytrix.data.model.EmojiEntry) -> Unit, + modifier: Modifier = Modifier, +) { + val expanded = remember { mutableStateMapOf().also { map -> packs.forEach { map[it.packId] = true } } } + LazyVerticalGrid( + columns = GridCells.Adaptive(56.dp), + modifier = modifier, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + packs.forEach { pack -> + val isExpanded = expanded[pack.packId] != false + item(key = "header_${pack.packId}", span = { GridItemSpan(maxLineSpan) }) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded[pack.packId] = !isExpanded } + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = pack.displayName, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isExpanded) { + items(pack.emojis, key = { "${pack.packId}_${it.shortcode}" }) { entry -> + Column( + modifier = Modifier + .clickable { onEmojiSelected(entry) } + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AsyncImage( + model = entry.resolvedUrl, + contentDescription = entry.shortcode, + modifier = Modifier.size(40.dp), + ) + Text( + text = entry.shortcode, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + @Composable private fun CustomEmojiGrid( pack: EmojiPack, diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt index 203a6bb..30e6c3a 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.foundation.clickable import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -20,6 +22,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -28,26 +31,41 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.example.fluffytrix.data.local.PreferencesManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.compose.koinInject +private sealed interface GiphyKeyStatus { + data object Idle : GiphyKeyStatus + data object Testing : GiphyKeyStatus + data object Invalid : GiphyKeyStatus +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( onBack: () -> Unit, onLogout: () -> Unit, + onEmojiPackManagement: () -> Unit = {}, ) { val preferencesManager: PreferencesManager = koinInject() val userId by preferencesManager.userId.collectAsState(initial = null) val homeserver by preferencesManager.homeserverUrl.collectAsState(initial = null) val deviceId by preferencesManager.deviceId.collectAsState(initial = null) val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsState(initial = false) + val savedGiphyKey by preferencesManager.tenorApiKey.collectAsState(initial = "") + var giphyKeyInput by remember { mutableStateOf("") } + var giphyKeyStatus by remember { mutableStateOf(GiphyKeyStatus.Idle) } val scope = rememberCoroutineScope() val context = LocalContext.current val appVersion = try { @@ -109,6 +127,91 @@ fun SettingsScreen( HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + SectionHeader("Customization") + SettingNavRow(label = "Emoji Packs", onClick = onEmojiPackManagement) + Column(modifier = Modifier.padding(vertical = 6.dp)) { + Text("GIPHY API Key (for GIF search)", style = MaterialTheme.typography.bodyMedium) + Text( + "Get a free key at developers.giphy.com → Create App", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(6.dp)) + if (savedGiphyKey.isNotBlank()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Key saved (${savedGiphyKey.take(8)}…)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Button( + onClick = { scope.launch { preferencesManager.setTenorApiKey("") } }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text("Remove key") + } + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = giphyKeyInput, + onValueChange = { + giphyKeyInput = it + giphyKeyStatus = GiphyKeyStatus.Idle + }, + placeholder = { Text("Paste API key…") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Button( + onClick = { + val key = giphyKeyInput.trim() + if (key.isBlank()) return@Button + giphyKeyStatus = GiphyKeyStatus.Testing + scope.launch { + val valid = withContext(Dispatchers.IO) { + try { + val url = "https://api.giphy.com/v1/gifs/trending?api_key=$key&limit=1" + val client = okhttp3.OkHttpClient() + val response = client.newCall(okhttp3.Request.Builder().url(url).build()).execute() + response.isSuccessful + } catch (_: Exception) { false } + } + if (valid) { + preferencesManager.setTenorApiKey(key) + giphyKeyInput = "" + giphyKeyStatus = GiphyKeyStatus.Idle + } else { + giphyKeyStatus = GiphyKeyStatus.Invalid + } + } + }, + enabled = giphyKeyInput.isNotBlank() && giphyKeyStatus != GiphyKeyStatus.Testing, + ) { + Text(if (giphyKeyStatus == GiphyKeyStatus.Testing) "Testing…" else "Save") + } + } + when (giphyKeyStatus) { + GiphyKeyStatus.Invalid -> Text( + "Invalid API key — check and try again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + else -> {} + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + SectionHeader("Notifications") SettingRow("Push notifications", "Enabled") @@ -173,3 +276,22 @@ private fun SettingRow(label: String, value: String) { ) } } + +@Composable +private fun SettingNavRow(label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 0000000..d2e2c8d --- /dev/null +++ b/app/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,4 @@ + + + +