diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b735d2f26a..5138ec4328 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,8 @@ androidx-webkit = "1.13.0" assertj = "3.27.3" +coil = "3.2.0" + dokka = "1.9.20" google-material = "1.12.0" @@ -79,6 +81,7 @@ androidx-compose-foundation = { group = "androidx.compose.foundation", name = "f androidx-compose-material = { group = "androidx.compose.material", name = "material", version.ref = "androidx-compose-material" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-compose-material3" } androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "androidx-compose-material-icons" } +androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "androidx-compose-runtime" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidx-compose-ui" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose-ui" } @@ -103,6 +106,9 @@ androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "a assertj = { group = "org.assertj", name = "assertj-core", version.ref = "assertj" } +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } + desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } google-material = { group = "com.google.android.material", name = "material", version.ref = "google-material" } @@ -141,6 +147,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " [bundles] -compose = ["androidx-compose-activity", "androidx-compose-animation", "androidx-compose-foundation", "androidx-compose-material", "androidx-compose-material3", "androidx-compose-material-icons", "androidx-compose-runtime", "androidx-compose-ui", "androidx-compose-ui-tooling"] +coil = ["coil-compose", "coil-network"] +compose = ["androidx-compose-activity", "androidx-compose-animation", "androidx-compose-foundation", "androidx-compose-material", "androidx-compose-material3", "androidx-compose-material-icons", "androidx-compose-navigation", "androidx-compose-runtime", "androidx-compose-ui", "androidx-compose-ui-tooling"] media3 = ["androidx-media3-session", "androidx-media3-common", "androidx-media3-exoplayer"] room = ["androidx-room-runtime", "androidx-room-ktx"] diff --git a/test-app/build.gradle.kts b/test-app/build.gradle.kts index 8b16252de5..6df50cb884 100644 --- a/test-app/build.gradle.kts +++ b/test-app/build.gradle.kts @@ -108,6 +108,9 @@ dependencies { implementation(libs.bundles.media3) + implementation(libs.bundles.compose) + implementation(libs.bundles.coil) + // Room database implementation(libs.bundles.room) ksp(libs.androidx.room.compiler) diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt index c7b13e7c83..b9b5ad171e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt @@ -7,56 +7,27 @@ package org.readium.r2.testapp import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.navigation.NavController -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupActionBarWithNavController -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.snackbar.Snackbar -class MainActivity : AppCompatActivity() { +class MainActivity : ComponentActivity() { - private lateinit var navController: NavController private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.container)) { v, insets -> - val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars()) - v.setPadding(statusBars.left, statusBars.top, statusBars.right, statusBars.bottom) - insets - } - - val navView: BottomNavigationView = findViewById(R.id.nav_view) - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - navController = navHostFragment.navController - val appBarConfiguration = AppBarConfiguration( - setOf( - R.id.navigation_bookshelf, - R.id.navigation_catalog_list, - R.id.navigation_about - ) - ) - setupActionBarWithNavController(navController, appBarConfiguration) - navView.setupWithNavController(navController) + setContent { + TestApp() + } viewModel.channel.receive(this) { handleEvent(it) } } - override fun onSupportNavigateUp(): Boolean { - return navController.navigateUp() || super.onSupportNavigateUp() - } - private fun handleEvent(event: MainViewModel.Event) { when (event) { is MainViewModel.Event.ImportPublicationSuccess -> diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt index baf86390e3..c132141c0a 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt @@ -7,16 +7,27 @@ package org.readium.r2.testapp import android.app.Application +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import org.readium.r2.testapp.domain.Bookshelf import org.readium.r2.testapp.domain.ImportError import org.readium.r2.testapp.utils.EventChannel +data class TopBarState( + val title: String = "Readium", + val actions: @Composable RowScope.() -> Unit = {} +) + class MainViewModel( application: Application, ) : AndroidViewModel(application) { @@ -27,6 +38,13 @@ class MainViewModel( val channel: EventChannel = EventChannel(Channel(Channel.UNLIMITED), viewModelScope) + private val _topBarState = MutableStateFlow(TopBarState()) + val topBarState: StateFlow = _topBarState.asStateFlow() + + fun updateTopBar(title: String, actions: @Composable RowScope.() -> Unit = {}) { + _topBarState.update { TopBarState(title, actions) } + } + init { app.bookshelf.channel.receiveAsFlow() .onEach { sendImportFeedback(it) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/TestApp.kt b/test-app/src/main/java/org/readium/r2/testapp/TestApp.kt new file mode 100644 index 0000000000..6e0e63202e --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/TestApp.kt @@ -0,0 +1,144 @@ +package org.readium.r2.testapp + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Book +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import org.readium.r2.shared.publication.Publication +import org.readium.r2.testapp.about.AboutScreen +import org.readium.r2.testapp.bookshelf.BookshelfScreen +import org.readium.r2.testapp.catalogs.CatalogFeedScreen +import org.readium.r2.testapp.catalogs.CatalogScreen +import org.readium.r2.testapp.catalogs.CatalogViewModel +import org.readium.r2.testapp.catalogs.PublicationScreen +import org.readium.r2.testapp.data.model.Catalog + +sealed class Screen(val route: String) { + + sealed class TopLevel( + route: String, + val title: String, + val icon: ImageVector + ) : Screen(route) { + object Bookshelf : TopLevel("bookshelf", "Bookshelf", Icons.Default.Book) + object Catalogs : TopLevel("catalogs", "Catalogs", Icons.Default.Explore) + object About : TopLevel("about", "About", Icons.Default.Info) + } + + object CatalogDetail : Screen("catalog_detail") + object Publication : Screen("publication") +} + +private val topLevelScreens = listOf( + Screen.TopLevel.Bookshelf, + Screen.TopLevel.Catalogs, + Screen.TopLevel.About, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TestApp(mainViewModel: MainViewModel = viewModel()) { + val navController = rememberNavController() + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + val topBarState by mainViewModel.topBarState.collectAsState() + + val catalogViewModel: CatalogViewModel = viewModel() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(topBarState.title, maxLines = 1) }, + actions = topBarState.actions, + navigationIcon = { + val isTopLevelDestination = topLevelScreens.any { it.route == currentRoute } + + if (!isTopLevelDestination) { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + } + ) + }, + bottomBar = { + NavigationBar { + topLevelScreens.forEach { screen -> + NavigationBarItem( + icon = { Icon(screen.icon, contentDescription = null) }, + label = { Text(screen.title) }, + selected = currentRoute == screen.route, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + + } + ) { innerPadding -> + NavHost( + navController, + startDestination = Screen.TopLevel.Bookshelf.route, + Modifier.padding(innerPadding) + ) { + composable(Screen.TopLevel.Bookshelf.route) { BookshelfScreen() } + composable(Screen.TopLevel.About.route) { AboutScreen(mainViewModel = mainViewModel) } + + composable(Screen.TopLevel.Catalogs.route) { + CatalogFeedScreen(mainViewModel = mainViewModel, navController = navController) + } + + composable(Screen.CatalogDetail.route) { + val catalog = navController.previousBackStackEntry + ?.savedStateHandle?.get("catalog") + + if (catalog != null) { + CatalogScreen( + catalog = catalog, + mainViewModel = mainViewModel, + catalogViewModel = catalogViewModel, + navController = navController, + onFacetClick = { /* TODO */ } + ) + } + } + + composable(Screen.Publication.route) { + PublicationScreen(catalogViewModel = catalogViewModel, mainViewModel = mainViewModel) + } + } + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/about/AboutFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/about/AboutFragment.kt deleted file mode 100644 index 801970a7a3..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/about/AboutFragment.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.about - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import org.readium.r2.testapp.R - -class AboutFragment : Fragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.fragment_about, container, false) - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/about/AboutScreen.kt b/test-app/src/main/java/org/readium/r2/testapp/about/AboutScreen.kt new file mode 100644 index 0000000000..a1a9c26532 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/about/AboutScreen.kt @@ -0,0 +1,143 @@ +package org.readium.r2.testapp.about + +import android.app.Application +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.readium.r2.testapp.MainViewModel +import org.readium.r2.testapp.R +import org.readium.r2.testapp.utils.compose.AppTheme + +@Composable +fun AboutScreen(mainViewModel: MainViewModel) { + + val title = stringResource(R.string.title_about) + + LaunchedEffect(Unit) { + mainViewModel.updateTopBar(title = title) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AppVersionInfo() + CopyrightInfo() + AcknowledgementsInfo() + } +} + +@Composable +private fun AppVersionInfo() { + Column { + SectionTitle(text = stringResource(R.string.app_version_header)) + Spacer(modifier = Modifier.height(8.dp)) + InfoRow( + label = stringResource(R.string.app_version_label), + value = stringResource(R.string.app_version) + ) + InfoRow( + label = stringResource(R.string.github_tab_label), + value = stringResource(R.string.github_tag) + ) + } +} + +@Composable +private fun CopyrightInfo() { + Column { + SectionTitle(text = stringResource(R.string.copyright_label)) + Spacer(modifier = Modifier.height(8.dp)) + InfoText(text = stringResource(R.string.copyright)) + InfoText( + text = stringResource(R.string.bsd_license_label), + contentDescription = stringResource(R.string.bsd_license_label_accessible) + ) + } +} + +@Composable +private fun AcknowledgementsInfo() { + Column { + SectionTitle(text = stringResource(R.string.acknowledgements_label)) + Spacer(modifier = Modifier.height(8.dp)) + InfoText(text = stringResource(R.string.acknowledgements_french_state)) + Image( + painter = painterResource(id = R.drawable.repfr), + contentDescription = stringResource(R.string.repfr), + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .padding(vertical = 16.dp), + alignment = Alignment.Center + ) + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, fontSize = 18.sp) + Text(text = value, fontSize = 18.sp) + } +} + +@Composable +private fun InfoText(text: String, contentDescription: String? = null) { + val modifier = if (contentDescription != null) { + Modifier.padding(vertical = 4.dp) + } else { + Modifier.padding(vertical = 4.dp) + } + Text( + text = text, + fontSize = 18.sp, + modifier = modifier + ) +} + +@Preview(showBackground = true) +@Composable +private fun AboutScreenPreview() { + val viewModel = MainViewModel(LocalContext.current.applicationContext as Application) + AppTheme { + AboutScreen(mainViewModel = viewModel) + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfAdapter.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfAdapter.kt deleted file mode 100644 index 476f107d0b..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfAdapter.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.bookshelf - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.squareup.picasso.Picasso -import java.io.File -import org.readium.r2.testapp.R -import org.readium.r2.testapp.data.model.Book -import org.readium.r2.testapp.databinding.ItemRecycleBookBinding -import org.readium.r2.testapp.utils.singleClick - -class BookshelfAdapter( - private val onBookClick: (Book) -> Unit, - private val onBookLongClick: (Book) -> Unit, -) : ListAdapter(BookListDiff()) { - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ViewHolder { - return ViewHolder( - ItemRecycleBookBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val book = getItem(position) - - viewHolder.bind(book) - } - - inner class ViewHolder(private val binding: ItemRecycleBookBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(book: Book) { - binding.bookshelfTitleText.text = book.title - Picasso.get() - .load(File(book.cover)) - .placeholder(R.drawable.cover) - .into(binding.bookshelfCoverImage) - binding.root.singleClick { - onBookClick(book) - } - binding.root.setOnLongClickListener { - onBookLongClick(book) - true - } - } - } - - private class BookListDiff : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldItem: Book, - newItem: Book, - ): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame( - oldItem: Book, - newItem: Book, - ): Boolean { - return oldItem.title == newItem.title && - oldItem.href == newItem.href && - oldItem.author == newItem.author && - oldItem.identifier == newItem.identifier - } - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt deleted file mode 100644 index fbdd44c13e..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.bookshelf - -import android.content.Intent -import android.graphics.Rect -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.webkit.URLUtil -import android.widget.EditText -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.readium.r2.shared.DelicateReadiumApi -import org.readium.r2.shared.util.AbsoluteUrl -import org.readium.r2.testapp.Application -import org.readium.r2.testapp.R -import org.readium.r2.testapp.data.model.Book -import org.readium.r2.testapp.databinding.FragmentBookshelfBinding -import org.readium.r2.testapp.opds.GridAutoFitLayoutManager -import org.readium.r2.testapp.reader.ReaderActivityContract -import org.readium.r2.testapp.utils.viewLifecycle - -class BookshelfFragment : Fragment() { - - private inner class OnViewAttachedListener : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(view: View) { - app.readium.onLcpDialogAuthenticationParentAttached(view) - } - - override fun onViewDetachedFromWindow(view: View) { - app.readium.onLcpDialogAuthenticationParentDetached() - } - } - - private val bookshelfViewModel: BookshelfViewModel by activityViewModels() - private lateinit var bookshelfAdapter: BookshelfAdapter - private lateinit var appStoragePickerLauncher: ActivityResultLauncher - private lateinit var sharedStoragePickerLauncher: ActivityResultLauncher> - private var binding: FragmentBookshelfBinding by viewLifecycle() - private var onViewAttachedListener: OnViewAttachedListener = OnViewAttachedListener() - - private val app: Application - get() = requireContext().applicationContext as Application - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = FragmentBookshelfBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - view.addOnAttachStateChangeListener(onViewAttachedListener) - - bookshelfViewModel.channel.receive(viewLifecycleOwner) { handleEvent(it) } - - bookshelfAdapter = BookshelfAdapter( - onBookClick = { book -> - book.id?.let { - bookshelfViewModel.openPublication(it) - } - }, - onBookLongClick = { book -> confirmDeleteBook(book) } - ) - - appStoragePickerLauncher = - registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> - uri?.let { - bookshelfViewModel.importPublicationFromStorage(it) - } - } - - sharedStoragePickerLauncher = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - uri?.let { - val takeFlags: Int = Intent.FLAG_GRANT_WRITE_URI_PERMISSION - requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - bookshelfViewModel.addPublicationFromStorage(it) - } - } - - binding.bookshelfBookList.apply { - setHasFixedSize(true) - layoutManager = GridAutoFitLayoutManager(requireContext(), 120) - adapter = bookshelfAdapter - addItemDecoration( - VerticalSpaceItemDecoration( - 10 - ) - ) - } - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - bookshelfViewModel.books.collectLatest { - bookshelfAdapter.submitList(it) - } - } - } - - binding.bookshelfAddBookFab.setOnClickListener { - var selected = 0 - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.add_book)) - .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> - dialog.cancel() - } - .setPositiveButton(getString(R.string.ok)) { _, _ -> - when (selected) { - 0 -> appStoragePickerLauncher.launch("*/*") - 1 -> sharedStoragePickerLauncher.launch(arrayOf("*/*")) - else -> askForRemoteUrl() - } - } - .setSingleChoiceItems(R.array.documentSelectorArray, 0) { _, which -> - selected = which - } - .show() - } - } - - @OptIn(DelicateReadiumApi::class) - private fun askForRemoteUrl() { - val urlEditText = EditText(requireContext()) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.add_book)) - .setMessage(R.string.enter_url) - .setView(urlEditText) - .setNegativeButton(R.string.cancel) { dialog, _ -> - dialog.cancel() - } - .setPositiveButton(getString(R.string.ok)) { _, _ -> - val url = AbsoluteUrl(urlEditText.text.toString()) - if (url == null || !URLUtil.isValidUrl(urlEditText.text.toString())) { - urlEditText.error = getString(R.string.invalid_url) - return@setPositiveButton - } - - bookshelfViewModel.addPublicationFromWeb(url) - } - .show() - } - - private fun handleEvent(event: BookshelfViewModel.Event) { - when (event) { - is BookshelfViewModel.Event.OpenPublicationError -> { - event.error.toUserError().show(requireActivity()) - } - - is BookshelfViewModel.Event.LaunchReader -> { - val intent = ReaderActivityContract().createIntent( - requireContext(), - event.arguments - ) - startActivity(intent) - } - } - } - - class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int) : - RecyclerView.ItemDecoration() { - - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State, - ) { - outRect.bottom = verticalSpaceHeight - } - } - - private fun deleteBook(book: Book) { - bookshelfViewModel.deletePublication(book) - } - - private fun confirmDeleteBook(book: Book) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.confirm_delete_book_title)) - .setMessage(getString(R.string.confirm_delete_book_text)) - .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> - dialog.cancel() - } - .setPositiveButton(getString(R.string.delete)) { dialog, _ -> - deleteBook(book) - dialog.dismiss() - } - .show() - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfScreen.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfScreen.kt new file mode 100644 index 0000000000..794a17a6f7 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfScreen.kt @@ -0,0 +1,270 @@ +package org.readium.r2.testapp.bookshelf + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import org.readium.r2.shared.util.AbsoluteUrl +import org.readium.r2.testapp.MainViewModel +import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Book +import org.readium.r2.testapp.reader.ReaderActivityContract +import org.readium.r2.testapp.shared.views.PublicationCoverItem + +@Composable +fun BookshelfScreen( + mainViewModel: MainViewModel = viewModel(), + viewModel: BookshelfViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + var showAddBookDialog by remember { mutableStateOf(false) } + var showAddUrlDialog by remember { mutableStateOf(false) } + var bookToDelete by remember { mutableStateOf(null) } + + val appStoragePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { viewModel.importPublicationFromStorage(it) } + } + + val sharedStoragePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + uri?.let { viewModel.importPublicationFromStorage(it) } + } + + LaunchedEffect(Unit) { + mainViewModel.updateTopBar( + title = context.getString(R.string.title_bookshelf), + actions = {} + ) + } + + LaunchedEffect(viewModel.channel) { + viewModel.channel.receive(lifecycleOwner = lifecycleOwner) { event -> + when (event) { + is BookshelfViewModel.Event.LaunchReader -> { + val intent = ReaderActivityContract().createIntent(context, event.arguments) + context.startActivity(intent) + } + is BookshelfViewModel.Event.OpenPublicationError -> { +// event.error.toUserError().show(requireActivity()) + } + } + } + } + + Scaffold( + floatingActionButton = { + FloatingActionButton(onClick = { showAddBookDialog = true }) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(id = R.string.add_book) + ) + } + } + ) { padding -> + if (uiState.books.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + contentPadding = padding, + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) { + items(uiState.books) { book -> + PublicationCoverItem( + imageUrl = book.cover, + title = book.title!!, + onClick = { viewModel.openPublication(book.id!!) }, + onLongClick = { bookToDelete = book } + ) + } + } + } + } + + if (showAddBookDialog) { + AddBookDialog( + onDismiss = { showAddBookDialog = false }, + onConfirm = { selectedIndex -> + showAddBookDialog = false + when (selectedIndex) { + 0 -> appStoragePickerLauncher.launch("*/*") + 1 -> sharedStoragePickerLauncher.launch(arrayOf("*/*")) + 2 -> showAddUrlDialog = true + } + } + ) + } + + if (showAddUrlDialog) { + AddUrlDialog( + onDismiss = { showAddUrlDialog = false }, + onConfirm = { url -> + val absoluteUrl = AbsoluteUrl(url) + if (absoluteUrl != null) { + viewModel.addPublicationFromWeb(absoluteUrl) + } + showAddUrlDialog = false + } + ) + } + + bookToDelete?.let { book -> + DeleteConfirmationDialog( + bookTitle = book.title!!, + onConfirm = { viewModel.deletePublication(book) }, + onDismiss = { bookToDelete = null } + ) + } +} + +@Composable +private fun AddBookDialog( + onDismiss: () -> Unit, + onConfirm: (selectedIndex: Int) -> Unit +) { + var selectedIndex by remember { mutableIntStateOf(0) } + val options = stringArrayResource(id = R.array.documentSelectorArray) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_book)) }, + text = { + Column { + options.forEachIndexed { index, text -> + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = (index == selectedIndex), + onClick = { selectedIndex = index } + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (index == selectedIndex), + onClick = { selectedIndex = index } + ) + Text( + text = text, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selectedIndex) }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun AddUrlDialog( + onDismiss: () -> Unit, + onConfirm: (url: String) -> Unit +) { + var url by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(id = R.string.add_book)) }, + text = { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(text = stringResource(id = R.string.enter_url)) } + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(url) }, + enabled = url.isNotBlank() + ) { + Text(stringResource(id = R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun DeleteConfirmationDialog( + bookTitle: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.confirm_delete_book_title)) }, + text = { Text(text = stringResource(R.string.confirm_delete_book_text, bookTitle)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt index 4262d7fc43..7ba4e8b7f4 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt @@ -10,7 +10,12 @@ import android.app.Application import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.readium.r2.shared.util.AbsoluteUrl import org.readium.r2.shared.util.toUrl @@ -21,11 +26,22 @@ import org.readium.r2.testapp.utils.EventChannel class BookshelfViewModel(application: Application) : AndroidViewModel(application) { - private val app get() = - getApplication() + private val app + get() = + getApplication() val channel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) - val books = app.bookRepository.books() + + private val _uiState = MutableStateFlow(BookshelfUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + app.bookRepository.books().collect { books -> + _uiState.update { it.copy(books = books) } + } + } + } fun deletePublication(book: Book) = viewModelScope.launch { @@ -47,7 +63,7 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio fun openPublication( bookId: Long, ) { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { app.readerRepository .open(bookId) .onFailure { @@ -71,3 +87,7 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio ) : Event() } } + +data class BookshelfUiState( + val books: List = emptyList() +) diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListAdapter.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListAdapter.kt deleted file mode 100644 index 63fedf2f22..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListAdapter.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.catalogs - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.navigation.Navigation -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import org.readium.r2.testapp.R -import org.readium.r2.testapp.data.model.Catalog -import org.readium.r2.testapp.databinding.ItemRecycleButtonBinding - -class CatalogFeedListAdapter(private val onLongClick: (Catalog) -> Unit) : - ListAdapter(CatalogListDiff()) { - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ViewHolder { - return ViewHolder( - ItemRecycleButtonBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val catalog = getItem(position) - - viewHolder.bind(catalog) - } - - inner class ViewHolder(private val binding: ItemRecycleButtonBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(catalog: Catalog) { - binding.catalogListButton.text = catalog.title - binding.catalogListButton.setOnClickListener { - val bundle = bundleOf(CATALOGFEED to catalog) - Navigation.findNavController(it) - .navigate(R.id.action_navigation_catalog_list_to_navigation_catalog, bundle) - } - binding.catalogListButton.setOnLongClickListener { - onLongClick(catalog) - true - } - } - } - - companion object { - const val CATALOGFEED = "catalogFeed" - } - - private class CatalogListDiff : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldItem: Catalog, - newItem: Catalog, - ): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame( - oldItem: Catalog, - newItem: Catalog, - ): Boolean { - return oldItem.title == newItem.title && - oldItem.href == newItem.href && - oldItem.type == newItem.type - } - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListFragment.kt deleted file mode 100644 index ca9287b222..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListFragment.kt +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.catalogs - -import android.content.Context -import android.graphics.Rect -import android.os.Bundle -import android.text.TextUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.webkit.URLUtil -import android.widget.EditText -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.readium.r2.testapp.R -import org.readium.r2.testapp.data.model.Catalog -import org.readium.r2.testapp.databinding.FragmentCatalogFeedListBinding -import org.readium.r2.testapp.utils.viewLifecycle - -class CatalogFeedListFragment : Fragment() { - - private val catalogFeedListViewModel: CatalogFeedListViewModel by viewModels() - private lateinit var catalogsAdapter: CatalogFeedListAdapter - private var binding: FragmentCatalogFeedListBinding by viewLifecycle() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - catalogFeedListViewModel.eventChannel.receive(this) { handleEvent(it) } - binding = FragmentCatalogFeedListBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val preferences = - requireContext().getSharedPreferences("org.readium.r2.testapp", Context.MODE_PRIVATE) - - catalogsAdapter = CatalogFeedListAdapter(onLongClick = { catalog -> onLongClick(catalog) }) - - binding.catalogFeedList.apply { - setHasFixedSize(true) - layoutManager = LinearLayoutManager(requireContext()) - adapter = catalogsAdapter - addItemDecoration( - VerticalSpaceItemDecoration( - 10 - ) - ) - } - - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - catalogFeedListViewModel.catalogs.collectLatest { - catalogsAdapter.submitList(it) - } - } - } - - val version = 2 - val VERSION_KEY = "OPDS_CATALOG_VERSION" - - if (preferences.getInt(VERSION_KEY, 0) < version) { - preferences.edit().putInt(VERSION_KEY, version).apply() - - val oPDS2Catalog = Catalog( - title = "OPDS 2.0 Test Catalog", - href = "https://test.opds.io/2.0/home.json", - type = 2 - ) - val oTBCatalog = Catalog( - title = "Open Textbooks Catalog", - href = "http://open.minitex.org/textbooks/", - type = 1 - ) - - catalogFeedListViewModel.insertCatalog(oPDS2Catalog) - catalogFeedListViewModel.insertCatalog(oTBCatalog) - } - - binding.catalogFeedAddCatalogFab.setOnClickListener { - val alertDialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.add_catalog)) - .setView(R.layout.add_catalog_dialog) - .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> - dialog.cancel() - } - .setPositiveButton(getString(R.string.save), null) - .show() - alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val title = alertDialog.findViewById(R.id.catalogTitle) - val url = alertDialog.findViewById(R.id.catalogUrl) - if (TextUtils.isEmpty(title?.text)) { - title?.error = getString(R.string.invalid_title) - } else if (TextUtils.isEmpty(url?.text)) { - url?.error = getString(R.string.invalid_url) - } else if (!URLUtil.isValidUrl(url?.text.toString())) { - url?.error = getString(R.string.invalid_url) - } else { - catalogFeedListViewModel.parseCatalog( - url?.text.toString(), - title?.text.toString() - ) - alertDialog.dismiss() - } - } - } - } - - private fun handleEvent(event: CatalogFeedListViewModel.Event) { - val message = - when (event) { - is CatalogFeedListViewModel.Event.FeedListEvent.CatalogParseFailed -> getString( - R.string.catalog_parse_error - ) - } - Snackbar.make( - requireView(), - message, - Snackbar.LENGTH_LONG - ).show() - } - - private fun deleteCatalogModel(catalogModelId: Long) { - catalogFeedListViewModel.deleteCatalog(catalogModelId) - } - - private fun onLongClick(catalog: Catalog) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.confirm_delete_catalog_title)) - .setMessage(getString(R.string.confirm_delete_catalog_text)) - .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> - dialog.cancel() - } - .setPositiveButton(getString(R.string.delete)) { dialog, _ -> - catalog.id?.let { deleteCatalogModel(it) } - dialog.dismiss() - } - .show() - } - - class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int) : - RecyclerView.ItemDecoration() { - - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State, - ) { - outRect.bottom = verticalSpaceHeight - } - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedScreen.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedScreen.kt new file mode 100644 index 0000000000..409e9c19d5 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedScreen.kt @@ -0,0 +1,182 @@ +package org.readium.r2.testapp.catalogs + +import androidx.compose.foundation.clickable +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import org.readium.r2.testapp.MainViewModel +import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Catalog + +@Composable +fun CatalogFeedScreen( + catalogViewModel: CatalogFeedListViewModel = viewModel(), + mainViewModel: MainViewModel, + navController: NavController +) { + val title = stringResource(R.string.title_catalogs) + + LaunchedEffect(Unit) { + mainViewModel.updateTopBar(title = title) + } + + val catalogs by catalogViewModel.catalogs.collectAsStateWithLifecycle(initialValue = emptyList()) + var showAddCatalogDialog by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + ) { + items(catalogs) { catalog -> + CatalogItem( + catalog = catalog, + onDelete = { catalogId -> + catalogViewModel.deleteCatalog(catalogId) + }, + onClick = { + navController.currentBackStackEntry?.savedStateHandle?.set( + "catalog", + catalog + ) + navController.navigate("catalog_detail") + } + ) + HorizontalDivider() + } + } + + FloatingActionButton( + onClick = { showAddCatalogDialog = true }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_catalog) + ) + } + + } + + if (showAddCatalogDialog) { + AddCatalogDialog( + onDismiss = { showAddCatalogDialog = false }, + onConfirm = { title, url -> + catalogViewModel.parseCatalog(url, title) + showAddCatalogDialog = false + } + ) + } +} + +@Composable +private fun CatalogItem( + catalog: Catalog, + onDelete: (id: Long) -> Unit, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = catalog.title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + catalog.id?.let { id -> onDelete(id) } + }, + enabled = (catalog.id != null) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete) + ) + } + } +} + +@Composable +private fun AddCatalogDialog( + onDismiss: () -> Unit, + onConfirm: (String, String) -> Unit +) { + var title by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.add_catalog)) }, + text = { + Column { + TextField( + value = title, + onValueChange = { title = it }, + label = { Text(text = stringResource(R.string.enter_title)) }, + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = url, + onValueChange = { url = it }, + label = { Text(text = stringResource(R.string.enter_url)) }, + singleLine = true + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(title, url) }, + enabled = title.isNotBlank() && url.isNotBlank() + ) { + Text(text = stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.cancel)) + } + } + ) +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt deleted file mode 100644 index 03ebc10868..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.catalogs - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.core.os.BundleCompat -import androidx.core.os.bundleOf -import androidx.core.view.MenuHost -import androidx.core.view.MenuProvider -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.navigation.Navigation -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.snackbar.Snackbar -import org.readium.r2.shared.opds.Facet -import org.readium.r2.testapp.MainActivity -import org.readium.r2.testapp.R -import org.readium.r2.testapp.bookshelf.BookshelfFragment -import org.readium.r2.testapp.catalogs.CatalogFeedListAdapter.Companion.CATALOGFEED -import org.readium.r2.testapp.data.model.Catalog -import org.readium.r2.testapp.databinding.FragmentCatalogBinding -import org.readium.r2.testapp.opds.GridAutoFitLayoutManager -import org.readium.r2.testapp.utils.viewLifecycle - -class CatalogFragment : Fragment() { - - private val catalogViewModel: CatalogViewModel by activityViewModels() - private lateinit var publicationAdapter: PublicationAdapter - private lateinit var groupAdapter: GroupAdapter - private lateinit var navigationAdapter: NavigationAdapter - private lateinit var catalog: Catalog - private var showFacetMenu = false - private lateinit var facets: List - private var binding: FragmentCatalogBinding by viewLifecycle() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - catalogViewModel.channel.receive(this) { handleEvent(it) } - - catalog = arguments?.let { BundleCompat.getParcelable(it, CATALOGFEED, Catalog::class.java) }!! - binding = FragmentCatalogBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - publicationAdapter = PublicationAdapter(catalogViewModel::publication::set) - navigationAdapter = NavigationAdapter(catalog.type) - groupAdapter = GroupAdapter(catalog.type, catalogViewModel::publication::set) - - binding.catalogNavigationList.apply { - layoutManager = LinearLayoutManager(requireContext()) - adapter = navigationAdapter - addItemDecoration( - CatalogFeedListFragment.VerticalSpaceItemDecoration( - 10 - ) - ) - } - - binding.catalogPublicationsList.apply { - layoutManager = GridAutoFitLayoutManager(requireContext(), 120) - adapter = publicationAdapter - addItemDecoration( - BookshelfFragment.VerticalSpaceItemDecoration( - 10 - ) - ) - } - - binding.catalogGroupList.apply { - layoutManager = LinearLayoutManager(requireContext()) - adapter = groupAdapter - } - - (activity as MainActivity).supportActionBar?.title = catalog.title - - catalogViewModel.parseCatalog(catalog) - binding.catalogProgressBar.visibility = View.VISIBLE - - val menuHost: MenuHost = requireActivity() - - menuHost.addMenuProvider( - object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menu.clear() - if (showFacetMenu) { - facets.let { - for (i in facets.indices) { - val submenu = menu.addSubMenu(facets[i].title) - for (link in facets[i].links) { - val item = submenu.add(link.title) - item.setOnMenuItemClickListener { - val catalog1 = Catalog( - title = link.title!!, - href = link.href.toString(), - type = catalog.type - ) - val bundle = bundleOf(CATALOGFEED to catalog1) - Navigation.findNavController(requireView()) - .navigate(R.id.action_navigation_catalog_self, bundle) - true - } - } - } - } - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return false - } - }, - viewLifecycleOwner, - Lifecycle.State.RESUMED - ) - } - - private fun handleEvent(event: CatalogViewModel.Event) { - when (event) { - is CatalogViewModel.Event.CatalogParseFailed -> { - Snackbar.make( - requireView(), - getString(R.string.failed_parsing_catalog), - Snackbar.LENGTH_LONG - ).show() - } - - is CatalogViewModel.Event.CatalogParseSuccess -> { - facets = event.result.feed?.facets ?: emptyList() - - if (facets.size > 0) { - showFacetMenu = true - } - requireActivity().invalidateOptionsMenu() - - navigationAdapter.submitList(event.result.feed!!.navigation) - publicationAdapter.submitList(event.result.feed!!.publications) - groupAdapter.submitList(event.result.feed!!.groups) - } - } - binding.catalogProgressBar.visibility = View.GONE - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogScreen.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogScreen.kt new file mode 100644 index 0000000000..e32ef8ce9e --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogScreen.kt @@ -0,0 +1,271 @@ +package org.readium.r2.testapp.catalogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import org.readium.r2.shared.opds.Facet +import org.readium.r2.shared.opds.Group +import org.readium.r2.shared.publication.Link +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.opds.images +import org.readium.r2.testapp.MainViewModel +import org.readium.r2.testapp.Screen +import org.readium.r2.testapp.data.model.Catalog +import org.readium.r2.testapp.shared.views.PublicationCoverItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatalogScreen( + catalog: Catalog, + mainViewModel: MainViewModel, + catalogViewModel: CatalogViewModel = viewModel(), + navController: NavController, + onFacetClick: (facet: Facet) -> Unit +) { + val state by catalogViewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(catalog) { + catalogViewModel.parseCatalog(catalog) + } + + LaunchedEffect(state) { + val feed = (state as? CatalogUiState.Success)?.parseData?.feed + mainViewModel.updateTopBar( + title = catalog.title, + actions = { + if (!feed?.facets.isNullOrEmpty()) { + FacetMenu( + facets = feed.facets, + onFacetClick = { link -> + + } + ) + } + } + ) + } + + val navigateToPublication = { publication: Publication -> + catalogViewModel.setPublication(publication) + navController.navigate("publication") + } + + when (val currentState = state) { + is CatalogUiState.Loading -> + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + + is CatalogUiState.Success -> { + val feed = currentState.parseData.feed + LazyColumn( + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + if (feed?.navigation?.isNotEmpty() == true) { + item { + NavigationSection( + links = feed.navigation, + onNavigationLinkClick = { link -> + val newCatalog = Catalog( + href = link.href.toString(), + title = link.title!!, + type = catalog.type, + id = null + ) + navController.currentBackStackEntry?.savedStateHandle?.set( + "catalog", + newCatalog + ) + navController.navigate(Screen.CatalogDetail.route) + } + ) + } + } + + if (feed?.publications?.isNotEmpty() == true) { + item { + PublicationGrid( + publications = feed.publications, + onPublicationClick = navigateToPublication + ) + } + } + + items(feed?.groups ?: emptyList()) { group -> + GroupRow( + group = group, + onPublicationClick = navigateToPublication, + onMoreClick = { + group.links.firstOrNull()?.let { link -> + val newCatalog = Catalog( + href = link.href.toString(), + title = group.title, + type = catalog.type, + id = null + ) + navController.currentBackStackEntry?.savedStateHandle?.set( + "catalog", + newCatalog + ) + navController.navigate(Screen.CatalogDetail.route) + } + } + ) + } + } + } + + is CatalogUiState.Error -> { + + } + } +} + +@Composable +private fun FacetMenu(facets: List, onFacetClick: (Facet) -> Unit) { + var expanded by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { expanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More") + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + facets.forEach { facet -> + DropdownMenuItem( + text = { Text(facet.title) }, + onClick = { + onFacetClick(facet) + expanded = false + } + ) + } + } + } +} + +@Composable +private fun NavigationSection(links: List, onNavigationLinkClick: (Link) -> Unit) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + links.forEach { link -> + Button( + onClick = { onNavigationLinkClick(link) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(link.title ?: "") + } + } + } +} + +@Composable +private fun PublicationGrid( + publications: List, + onPublicationClick: (Publication) -> Unit +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(400.dp) + ) { + items(publications) { publication -> + val imageUrl = publication.linkWithRel("http://opds-spec.org/image/thumbnail")?.href?.toString() + ?: publication.images.firstOrNull()?.href?.toString() + PublicationCoverItem( + imageUrl = imageUrl, + title = publication.metadata.title!!, + onClick = { onPublicationClick(publication) } + ) + } + } +} + +@Composable +private fun GroupRow( + group: Group, + onPublicationClick: (Publication) -> Unit, + onMoreClick: () -> Unit +) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = group.title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f) + ) + if (group.links.isNotEmpty()) { + IconButton(onClick = onMoreClick) { + Icon(Icons.AutoMirrored.Default.ArrowForward, contentDescription = "More") + } + } + } + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(group.publications) { publication -> + val imageUrl = publication.linkWithRel("http://opds-spec.org/image/thumbnail")?.href?.toString() + ?: publication.images.firstOrNull()?.href?.toString() + PublicationCoverItem( + imageUrl = imageUrl, + title = publication.metadata.title!!, + onClick = { onPublicationClick(publication) } + ) + } + } + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt index 3128c1dd17..6d404758c2 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt @@ -9,7 +9,9 @@ package org.readium.r2.testapp.catalogs import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.readium.r2.opds.OPDS1Parser import org.readium.r2.opds.OPDS2Parser @@ -19,17 +21,20 @@ import org.readium.r2.shared.util.AbsoluteUrl import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.testapp.data.model.Catalog -import org.readium.r2.testapp.utils.EventChannel import timber.log.Timber class CatalogViewModel(application: Application) : AndroidViewModel(application) { - val channel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) + private val _uiState = MutableStateFlow(CatalogUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _publication = MutableStateFlow(null) + val publication: StateFlow = _publication.asStateFlow() - lateinit var publication: Publication private val app = getApplication() fun parseCatalog(catalog: Catalog) = viewModelScope.launch { + _uiState.value = CatalogUiState.Loading var parseRequest: Try? = null catalog.href.let { href -> AbsoluteUrl(href) @@ -42,23 +47,33 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) } } } - parseRequest?.onSuccess { - channel.send(Event.CatalogParseSuccess(it)) + parseRequest?.onSuccess { parseData -> + _uiState.value = CatalogUiState.Success(parseData) } parseRequest?.onFailure { Timber.e(it) - channel.send(Event.CatalogParseFailed) + _uiState.value = CatalogUiState.Error("Failed to parse catalog") } } + fun setPublication(publication: Publication) { + _publication.value = publication + } + fun downloadPublication(publication: Publication) = viewModelScope.launch { app.bookshelf.importPublicationFromOpds(publication) } +} - sealed class Event { +sealed interface CatalogUiState { - object CatalogParseFailed : Event() + data object Loading : CatalogUiState - class CatalogParseSuccess(val result: ParseData) : Event() - } + data class Success( + val parseData: ParseData, + ) : CatalogUiState + + data class Error( + val error: String, + ) : CatalogUiState } diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/GroupAdapter.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/GroupAdapter.kt deleted file mode 100644 index 907f752d11..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/GroupAdapter.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.catalogs - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.navigation.Navigation -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import org.readium.r2.shared.opds.Group -import org.readium.r2.shared.publication.Publication -import org.readium.r2.testapp.R -import org.readium.r2.testapp.data.model.Catalog -import org.readium.r2.testapp.databinding.ItemGroupViewBinding - -class GroupAdapter( - val type: Int, - private val setModelPublication: (Publication) -> Unit, -) : - ListAdapter(GroupDiff()) { - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ViewHolder { - return ViewHolder( - ItemGroupViewBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val group = getItem(position) - - viewHolder.bind(group) - } - - inner class ViewHolder(private val binding: ItemGroupViewBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(group: Group) { - binding.groupViewGroupPublications.itemRecycleHeaderTitle.text = group.title - if (group.links.size > 0) { - binding.groupViewGroupPublications.itemRecycleMoreButton.visibility = View.VISIBLE - binding.groupViewGroupPublications.itemRecycleMoreButton.setOnClickListener { - val catalog1 = Catalog( - href = group.links.first().href.toString(), - title = group.title, - type = type - ) - val bundle = bundleOf(CatalogFeedListAdapter.CATALOGFEED to catalog1) - Navigation.findNavController(it) - .navigate(R.id.action_navigation_catalog_self, bundle) - } - } - binding.groupViewGroupPublications.recyclerView.apply { - layoutManager = LinearLayoutManager(binding.root.context) - (layoutManager as LinearLayoutManager).orientation = - LinearLayoutManager.HORIZONTAL - adapter = PublicationAdapter(setModelPublication).apply { - submitList(group.publications) - } - } - binding.groupViewGroupLinks.apply { - layoutManager = LinearLayoutManager(binding.root.context) - adapter = NavigationAdapter(type).apply { - submitList(group.navigation) - } - addItemDecoration( - CatalogFeedListFragment.VerticalSpaceItemDecoration( - 10 - ) - ) - } - } - } - - private class GroupDiff : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldItem: Group, - newItem: Group, - ): Boolean { - return oldItem == newItem - } - - override fun areContentsTheSame( - oldItem: Group, - newItem: Group, - ): Boolean { - return oldItem == newItem - } - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/NavigationAdapter.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/NavigationAdapter.kt deleted file mode 100644 index 0938436b8c..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/NavigationAdapter.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.catalogs - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.navigation.Navigation -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import org.readium.r2.shared.publication.Link -import org.readium.r2.testapp.R -import org.readium.r2.testapp.data.model.Catalog -import org.readium.r2.testapp.databinding.ItemRecycleButtonBinding - -class NavigationAdapter(val type: Int) : - ListAdapter(LinkDiff()) { - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ViewHolder { - return ViewHolder( - ItemRecycleButtonBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val link = getItem(position) - - viewHolder.bind(link) - } - - inner class ViewHolder(private val binding: ItemRecycleButtonBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(link: Link) { - binding.catalogListButton.text = link.title - binding.catalogListButton.setOnClickListener { - val catalog1 = Catalog( - href = link.href.toString(), - title = link.title!!, - type = type - ) - val bundle = bundleOf(CatalogFeedListAdapter.CATALOGFEED to catalog1) - Navigation.findNavController(it) - .navigate(R.id.action_navigation_catalog_self, bundle) - } - } - } - - private class LinkDiff : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldItem: Link, - newItem: Link, - ): Boolean { - return oldItem == newItem - } - - override fun areContentsTheSame( - oldItem: Link, - newItem: Link, - ): Boolean { - return oldItem == newItem - } - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationAdapter.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationAdapter.kt deleted file mode 100644 index 617b6a6938..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationAdapter.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.catalogs - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.navigation.Navigation -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.squareup.picasso.Picasso -import org.readium.r2.shared.publication.Publication -import org.readium.r2.shared.publication.opds.images -import org.readium.r2.testapp.R -import org.readium.r2.testapp.databinding.ItemRecycleCatalogBinding - -class PublicationAdapter( - private val setModelPublication: (Publication) -> Unit, -) : - ListAdapter(PublicationListDiff()) { - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ViewHolder { - return ViewHolder( - ItemRecycleCatalogBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val publication = getItem(position) - - viewHolder.bind(publication) - } - - inner class ViewHolder(private val binding: ItemRecycleCatalogBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(publication: Publication) { - binding.catalogListTitleText.text = publication.metadata.title - - publication.linkWithRel("http://opds-spec.org/image/thumbnail")?.let { link -> - Picasso.get().load(link.href.toString()) - .into(binding.catalogListCoverImage) - } ?: run { - if (publication.images.isNotEmpty()) { - Picasso.get() - .load(publication.images.first().href.toString()).into( - binding.catalogListCoverImage - ) - } - } - - binding.root.setOnClickListener { - setModelPublication(publication) - Navigation.findNavController(it) - .navigate(R.id.action_navigation_catalog_to_navigation_catalog_detail) - } - } - } - - private class PublicationListDiff : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldItem: Publication, - newItem: Publication, - ): Boolean { - return oldItem.metadata.identifier == newItem.metadata.identifier - } - - override fun areContentsTheSame( - oldItem: Publication, - newItem: Publication, - ): Boolean { - return oldItem.manifest == newItem.manifest - } - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt deleted file mode 100644 index 5ba3cb1534..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2021 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.testapp.catalogs - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import com.squareup.picasso.Picasso -import org.readium.r2.shared.publication.Publication -import org.readium.r2.shared.publication.opds.images -import org.readium.r2.testapp.MainActivity -import org.readium.r2.testapp.databinding.FragmentPublicationDetailBinding - -class PublicationDetailFragment : Fragment() { - - private var publication: Publication? = null - private val catalogViewModel: CatalogViewModel by activityViewModels() - - private var _binding: FragmentPublicationDetailBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentPublicationDetailBinding.inflate( - inflater, - container, - false - ) - publication = catalogViewModel.publication - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - (activity as MainActivity).supportActionBar?.title = publication?.metadata?.title - - publication?.images?.firstOrNull() - ?.let { Picasso.get().load(it.href.toString()) } - ?.into(binding.catalogDetailCoverImage) - - binding.catalogDetailDescriptionText.text = publication?.metadata?.description - binding.catalogDetailTitleText.text = publication?.metadata?.title - - binding.catalogDetailDownloadButton.setOnClickListener { - publication?.let { it1 -> - catalogViewModel.downloadPublication( - it1 - ) - } - } - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationScreen.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationScreen.kt new file mode 100644 index 0000000000..2733a5ab12 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationScreen.kt @@ -0,0 +1,115 @@ +package org.readium.r2.testapp.catalogs + +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.opds.images +import org.readium.r2.testapp.MainViewModel +import org.readium.r2.testapp.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PublicationScreen( + mainViewModel: MainViewModel, + catalogViewModel: CatalogViewModel = viewModel() +) { + + val publication by catalogViewModel.publication.collectAsState() + + LaunchedEffect(Unit) { + mainViewModel.updateTopBar(title = "Publication") + } + + publication?.let { pub -> + PublicationDetailContent( + publication = pub, + onDownloadClick = { catalogViewModel.downloadPublication(pub) } + ) + } + +} + +@Composable +private fun PublicationDetailContent( + publication: Publication, + onDownloadClick: () -> Unit +) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + val imageUrl = publication.linkWithRel("http://opds-spec.org/image/thumbnail")?.href?.toString() + ?: publication.images.firstOrNull()?.href?.toString() + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = "Publication cover", + contentScale = ContentScale.Fit, + modifier = Modifier.height(240.dp) + ) + + Text( + text = publication.metadata.title ?: "", + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + + Text( + text = publication.metadata.authors.joinToString { it.name }, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + publication.metadata.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium + ) + } + + Spacer(modifier = Modifier.weight(1.0f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + + Button(onClick = onDownloadClick) { + Text(stringResource(id = R.string.catalog_detail_download_button)) + } + } + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/shared/views/PublicationCoverItem.kt b/test-app/src/main/java/org/readium/r2/testapp/shared/views/PublicationCoverItem.kt new file mode 100644 index 0000000000..8e3ea4be72 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/shared/views/PublicationCoverItem.kt @@ -0,0 +1,58 @@ +package org.readium.r2.testapp.shared.views + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PublicationCoverItem( + imageUrl: String?, + title: String, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null +) { + Card( + modifier = Modifier + .width(120.dp) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + ) { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = "Publication cover", + contentScale = ContentScale.Crop, + modifier = Modifier + .height(160.dp) + ) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(8.dp) + ) + } + } +} diff --git a/test-app/src/main/res/layout/activity_main.xml b/test-app/src/main/res/layout/activity_main.xml deleted file mode 100644 index b9036b0090..0000000000 --- a/test-app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/test-app/src/main/res/layout/add_catalog_dialog.xml b/test-app/src/main/res/layout/add_catalog_dialog.xml deleted file mode 100644 index 41a8d6e308..0000000000 --- a/test-app/src/main/res/layout/add_catalog_dialog.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/test-app/src/main/res/layout/fragment_about.xml b/test-app/src/main/res/layout/fragment_about.xml deleted file mode 100644 index 2ca28f7578..0000000000 --- a/test-app/src/main/res/layout/fragment_about.xml +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test-app/src/main/res/layout/fragment_bookshelf.xml b/test-app/src/main/res/layout/fragment_bookshelf.xml deleted file mode 100644 index 6b3f56e2f0..0000000000 --- a/test-app/src/main/res/layout/fragment_bookshelf.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/test-app/src/main/res/layout/fragment_catalog.xml b/test-app/src/main/res/layout/fragment_catalog.xml deleted file mode 100644 index 96886babd3..0000000000 --- a/test-app/src/main/res/layout/fragment_catalog.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test-app/src/main/res/layout/fragment_catalog_feed_list.xml b/test-app/src/main/res/layout/fragment_catalog_feed_list.xml deleted file mode 100644 index 5aa0a78e29..0000000000 --- a/test-app/src/main/res/layout/fragment_catalog_feed_list.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/test-app/src/main/res/layout/fragment_publication_detail.xml b/test-app/src/main/res/layout/fragment_publication_detail.xml deleted file mode 100644 index 13151bd883..0000000000 --- a/test-app/src/main/res/layout/fragment_publication_detail.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - -