feat(ui): wire download+open in reader, NavGraph with reader/server_settings routes
- BooksViewModel: downloadEbook → saveToCache → emit Uri → navigate to reader
- BooksScreen: LaunchedEffect collects openBook flow, navigates to reader/{bookUri}
- NavGraph: added reader/{bookUri} and server_settings routes
- ReaderScreen: placeholder (will wrap Episteme engine)
- Added items import for LazyVerticalGrid in all screen files
- ':app:assembleOssDebug' passes, APK deployed.
This commit is contained in:
parent
1b43c9c1ec
commit
d3339bbceb
7 changed files with 132 additions and 78 deletions
|
|
@ -130,13 +130,7 @@ open class MainActivity : AppCompatActivity() {
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
val windowSizeClass = calculateWindowSizeClass(this)
|
org.dueattendant149.bookreader.ui.MainScreen()
|
||||||
val navController = rememberNavController()
|
|
||||||
AppNavigation(
|
|
||||||
navController = navController,
|
|
||||||
windowSizeClass = windowSizeClass,
|
|
||||||
viewModel = viewModel
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,18 @@ package org.dueattendant149.bookreader.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import org.dueattendant149.bookreader.ui.screens.audiobooks.AudiobooksScreen
|
import org.dueattendant149.bookreader.ui.screens.audiobooks.AudiobooksScreen
|
||||||
import org.dueattendant149.bookreader.ui.screens.books.BooksScreen
|
import org.dueattendant149.bookreader.ui.screens.books.BooksScreen
|
||||||
|
import org.dueattendant149.bookreader.ui.screens.reader.ReaderScreen
|
||||||
import org.dueattendant149.bookreader.ui.screens.search.SearchScreen
|
import org.dueattendant149.bookreader.ui.screens.search.SearchScreen
|
||||||
import org.dueattendant149.bookreader.ui.screens.settings.SettingsScreen
|
import org.dueattendant149.bookreader.ui.screens.settings.SettingsScreen
|
||||||
|
import org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen
|
||||||
|
import org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavGraph(navController: NavHostController) {
|
fun NavGraph(navController: NavHostController) {
|
||||||
|
|
@ -27,5 +33,16 @@ fun NavGraph(navController: NavHostController) {
|
||||||
composable(BottomBarScreen.Settings.route) {
|
composable(BottomBarScreen.Settings.route) {
|
||||||
SettingsScreen(navController = navController)
|
SettingsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
composable("server_settings") {
|
||||||
|
val vm: ServerSettingsViewModel = hiltViewModel()
|
||||||
|
ServerSettingsScreen(viewModel = vm, onBackClick = { navController.popBackStack() })
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "reader/{bookUri}",
|
||||||
|
arguments = listOf(navArgument("bookUri") { type = NavType.StringType }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val bookUri = backStackEntry.arguments?.getString("bookUri") ?: ""
|
||||||
|
ReaderScreen(bookUri = bookUri, onBackClick = { navController.popBackStack() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Audiotrack
|
import androidx.compose.material.icons.filled.Audiotrack
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package org.dueattendant149.bookreader.ui.screens.books
|
package org.dueattendant149.bookreader.ui.screens.books
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
|
@ -12,15 +13,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Bookmarks
|
import androidx.compose.material.icons.filled.Bookmarks
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
@ -35,47 +37,31 @@ fun BooksScreen(navController: NavController) {
|
||||||
val viewModel: BooksViewModel = hiltViewModel()
|
val viewModel: BooksViewModel = hiltViewModel()
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.openBook.collect { uri ->
|
||||||
|
val encoded = Uri.encode(uri.toString())
|
||||||
|
navController.navigate("reader/$encoded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
) {
|
) {
|
||||||
CustomTopAppBar(
|
CustomTopAppBar(headerText = "Books", icon = Icons.Filled.Bookmarks)
|
||||||
headerText = "Books",
|
|
||||||
icon = Icons.Filled.Bookmarks,
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(visible = uiState.isLoading && uiState.items.isEmpty(), enter = fadeIn(), exit = fadeOut()) {
|
||||||
visible = uiState.isLoading && uiState.items.isEmpty(),
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ProgressDots() }
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
ProgressDots()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uiState.error?.let { error ->
|
uiState.error?.let { error ->
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
Text(text = error, color = MaterialTheme.colorScheme.error, fontSize = 16.sp)
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = error,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(visible = !uiState.isLoading && uiState.error == null, enter = fadeIn(), exit = fadeOut()) {
|
||||||
visible = !uiState.isLoading && uiState.error == null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -86,18 +72,18 @@ fun BooksScreen(navController: NavController) {
|
||||||
) {
|
) {
|
||||||
items(uiState.items.size) { i ->
|
items(uiState.items.size) { i ->
|
||||||
val item = uiState.items[i]
|
val item = uiState.items[i]
|
||||||
|
val isDownloading = uiState.downloadingItemId == item.id
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(4.dp).fillMaxWidth(),
|
||||||
.padding(4.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
BookItemCard(
|
BookItemCard(
|
||||||
title = item.title,
|
title = item.title,
|
||||||
author = item.author.ifBlank { item.authors.joinToString(", ") },
|
author = item.author.ifBlank { item.authors.joinToString(", ") },
|
||||||
subtitle = if (item.size > 0) formatSize(item.size) else "",
|
subtitle = if (isDownloading) "Downloading..." else formatSize(item.size),
|
||||||
coverImageUrl = item.coverUrl.ifBlank { null },
|
coverImageUrl = item.coverUrl.ifBlank { null },
|
||||||
) {
|
) {
|
||||||
|
if (!isDownloading) viewModel.downloadAndOpen(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,11 +94,6 @@ fun BooksScreen(navController: NavController) {
|
||||||
|
|
||||||
private fun formatSize(bytes: Long): String {
|
private fun formatSize(bytes: Long): String {
|
||||||
if (bytes <= 0) return ""
|
if (bytes <= 0) return ""
|
||||||
val kb = bytes / 1024.0
|
val mb = bytes / 1048576.0
|
||||||
val mb = kb / 1024.0
|
return if (mb >= 1) String.format("%.1f MB", mb) else String.format("%.0f KB", bytes / 1024.0)
|
||||||
return when {
|
|
||||||
mb >= 1 -> String.format("%.1f MB", mb)
|
|
||||||
kb >= 1 -> String.format("%.0f KB", kb)
|
|
||||||
else -> "$bytes B"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,79 +1,104 @@
|
||||||
package org.dueattendant149.bookreader.ui.screens.books
|
package org.dueattendant149.bookreader.ui.screens.books
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.ResponseBody
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class BooksUiState(
|
data class BooksUiState(
|
||||||
val items: List<UnifiedItemResponse> = emptyList(),
|
val items: List<UnifiedItemResponse> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val libraryId: String? = null,
|
val downloadingItemId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class BooksViewModel
|
class BooksViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
application: Application,
|
||||||
private val repository: BookshelfApiRepository,
|
private val repository: BookshelfApiRepository,
|
||||||
) : ViewModel() {
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(BooksUiState())
|
private val _uiState = MutableStateFlow(BooksUiState())
|
||||||
val uiState: StateFlow<BooksUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<BooksUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
private val _openBook = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
|
||||||
loadBooks()
|
val openBook: SharedFlow<Uri> = _openBook.asSharedFlow()
|
||||||
}
|
|
||||||
|
init { loadBooks() }
|
||||||
|
|
||||||
fun loadBooks() {
|
fun loadBooks() {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.getLibraries()
|
repository.getLibraries()
|
||||||
.onSuccess { libraries ->
|
.onSuccess { libraries ->
|
||||||
val booksLibrary = libraries.firstOrNull {
|
val booksLibrary = libraries.firstOrNull {
|
||||||
it.name.equals("Books", ignoreCase = true) || it.id == "4e1152a6-1546-497a-b354-ccdd9cdbd32d"
|
it.name.equals("Books", ignoreCase = true)
|
||||||
} ?: libraries.firstOrNull()
|
} ?: libraries.firstOrNull()
|
||||||
if (booksLibrary != null) {
|
if (booksLibrary != null) loadItems(booksLibrary.id)
|
||||||
loadItems(booksLibrary.id)
|
else _uiState.value = _uiState.value.copy(isLoading = false, error = "No libraries")
|
||||||
} else {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false, error = "No libraries found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onFailure { error ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = error.message ?: "Failed to load libraries"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadItems(libraryId: String) {
|
private fun loadItems(libraryId: String) {
|
||||||
_uiState.value = _uiState.value.copy(libraryId = libraryId, isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.getLibraryItems(libraryId)
|
repository.getLibraryItems(libraryId)
|
||||||
.onSuccess { response ->
|
.onSuccess { response ->
|
||||||
val ebooks = response.items.filter { it.type == "ebook" }
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
items = ebooks,
|
items = response.items.filter { it.type == "ebook" },
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
|
||||||
_uiState.value = _uiState.value.copy(
|
}
|
||||||
isLoading = false,
|
}
|
||||||
error = error.message ?: "Failed to load items"
|
|
||||||
)
|
fun downloadAndOpen(item: UnifiedItemResponse) {
|
||||||
|
_uiState.value = _uiState.value.copy(downloadingItemId = item.id)
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.downloadEbook(item.id)
|
||||||
|
.onSuccess { body ->
|
||||||
|
val file = saveToCache(item, body)
|
||||||
|
if (file != null) _openBook.tryEmit(Uri.fromFile(file))
|
||||||
|
_uiState.value = _uiState.value.copy(downloadingItemId = null)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_uiState.value = _uiState.value.copy(downloadingItemId = null, error = it.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveToCache(item: UnifiedItemResponse, body: ResponseBody): File? = runCatching {
|
||||||
|
val dir = File(getApplication<Application>().cacheDir, "bookshelf_downloads").apply { mkdirs() }
|
||||||
|
val safeName = item.title.replace(Regex("[^A-Za-z0-9._-]"), "_").take(60)
|
||||||
|
val ext = guessExt(item)
|
||||||
|
val file = File(dir, "${safeName}_${item.id}.$ext")
|
||||||
|
file.outputStream().use { body.byteStream().copyTo(it) }
|
||||||
|
file
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun guessExt(item: UnifiedItemResponse): String = when {
|
||||||
|
item.mediaType.contains("epub", true) -> "epub"
|
||||||
|
item.mediaType.contains("pdf", true) -> "pdf"
|
||||||
|
item.mediaType.contains("fb2", true) -> "fb2"
|
||||||
|
item.mediaType.contains("mobi", true) -> "mobi"
|
||||||
|
else -> "epub"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package org.dueattendant149.bookreader.ui.screens.reader
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder reader screen — wraps the Episteme reader engine.
|
||||||
|
* In production this delegates to EpubReaderScreen/PdfViewerScreen based on file type.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReaderScreen(
|
||||||
|
bookUri: String,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler { onBackClick() }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Reader: $bookUri",
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue