From 8ddf4e55b79077ed1df034ac155b181d1e549d69 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Thu, 5 Jun 2025 21:57:16 -0500 Subject: [PATCH 01/10] Restart testapp compose efforts --- gradle/libs.versions.toml | 9 +- test-app/build.gradle.kts | 3 + .../org/readium/r2/testapp/MainActivity.kt | 41 +-- .../java/org/readium/r2/testapp/TestApp.kt | 121 ++++++++ .../readium/r2/testapp/about/AboutFragment.kt | 25 -- .../readium/r2/testapp/about/AboutScreen.kt | 124 ++++++++ .../r2/testapp/bookshelf/BookshelfScreen.kt | 9 + .../catalogs/CatalogFeedListAdapter.kt | 77 ----- .../catalogs/CatalogFeedListFragment.kt | 172 ----------- .../r2/testapp/catalogs/CatalogFeedScreen.kt | 169 +++++++++++ .../r2/testapp/catalogs/CatalogFragment.kt | 158 ---------- .../r2/testapp/catalogs/CatalogScreen.kt | 282 ++++++++++++++++++ .../r2/testapp/catalogs/CatalogViewModel.kt | 31 +- .../r2/testapp/catalogs/GroupAdapter.kt | 105 ------- .../r2/testapp/catalogs/NavigationAdapter.kt | 77 ----- .../r2/testapp/catalogs/PublicationAdapter.kt | 83 ------ .../catalogs/PublicationDetailFragment.kt | 62 ---- .../r2/testapp/catalogs/PublicationScreen.kt | 118 ++++++++ .../src/main/res/layout/activity_main.xml | 32 -- .../main/res/layout/add_catalog_dialog.xml | 25 -- .../src/main/res/layout/fragment_about.xml | 150 ---------- .../src/main/res/layout/fragment_catalog.xml | 56 ---- .../res/layout/fragment_catalog_feed_list.xml | 27 -- .../layout/fragment_publication_detail.xml | 54 ---- .../src/main/res/layout/item_group_view.xml | 22 -- .../main/res/layout/item_recycle_button.xml | 18 -- .../main/res/layout/item_recycle_catalog.xml | 52 ---- .../src/main/res/menu/bottom_nav_menu.xml | 19 -- test-app/src/main/res/navigation/about.xml | 14 - .../src/main/res/navigation/bookshelf.xml | 14 - test-app/src/main/res/navigation/catalogs.xml | 35 --- .../src/main/res/navigation/navigation.xml | 11 - test-app/src/main/res/values/strings.xml | 2 +- 33 files changed, 861 insertions(+), 1336 deletions(-) create mode 100644 test-app/src/main/java/org/readium/r2/testapp/TestApp.kt delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/about/AboutFragment.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/about/AboutScreen.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfScreen.kt delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListAdapter.kt delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListFragment.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedScreen.kt delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogScreen.kt delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/GroupAdapter.kt delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/NavigationAdapter.kt delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationAdapter.kt delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationScreen.kt delete mode 100644 test-app/src/main/res/layout/activity_main.xml delete mode 100644 test-app/src/main/res/layout/add_catalog_dialog.xml delete mode 100644 test-app/src/main/res/layout/fragment_about.xml delete mode 100644 test-app/src/main/res/layout/fragment_catalog.xml delete mode 100644 test-app/src/main/res/layout/fragment_catalog_feed_list.xml delete mode 100644 test-app/src/main/res/layout/fragment_publication_detail.xml delete mode 100644 test-app/src/main/res/layout/item_group_view.xml delete mode 100644 test-app/src/main/res/layout/item_recycle_button.xml delete mode 100644 test-app/src/main/res/layout/item_recycle_catalog.xml delete mode 100644 test-app/src/main/res/menu/bottom_nav_menu.xml delete mode 100644 test-app/src/main/res/navigation/about.xml delete mode 100644 test-app/src/main/res/navigation/bookshelf.xml delete mode 100644 test-app/src/main/res/navigation/catalogs.xml delete mode 100644 test-app/src/main/res/navigation/navigation.xml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f63d5e0e5..f0256b9041 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,8 @@ androidx-webkit = "1.13.0" assertj = "3.26.3" +coil = "3.2.0" + dokka = "1.9.20" google-material = "1.12.0" @@ -76,6 +78,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" } +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" } @@ -100,6 +103,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" } @@ -138,6 +144,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/TestApp.kt b/test-app/src/main/java/org/readium/r2/testapp/TestApp.kt new file mode 100644 index 0000000000..8d21936e52 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/TestApp.kt @@ -0,0 +1,121 @@ +package org.readium.r2.testapp + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +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.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.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +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.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() { + val navController = rememberNavController() + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + Scaffold( + topBar = { + TopAppBar(title = { Text("Readium") }) + }, + 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.Catalogs.route, + Modifier.padding(innerPadding) + ) { + composable(Screen.TopLevel.Bookshelf.route) { BookshelfScreen() } + composable(Screen.TopLevel.About.route) { AboutScreen() } + + composable(Screen.TopLevel.Catalogs.route) { + CatalogFeedScreen(navController = navController) + } + + composable(Screen.CatalogDetail.route) { + val catalog = navController.previousBackStackEntry + ?.savedStateHandle?.get("catalog") + + if (catalog != null) { + CatalogScreen( + catalog = catalog, + navController = navController, + onFacetClick = { /* TODO */ }, + ) + } + } + + composable(Screen.Publication.route) { + val publication = navController.previousBackStackEntry + ?.savedStateHandle?.get("publication") + + PublicationScreen(publication = publication) + } + } + } +} 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..6410055b95 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/about/AboutScreen.kt @@ -0,0 +1,124 @@ +package org.readium.r2.testapp.about + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.R +import org.readium.r2.testapp.utils.compose.AppTheme + +@Composable +fun AboutScreen() { + 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() { + AppTheme { + AboutScreen() + } +} 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..ed14cce8b5 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfScreen.kt @@ -0,0 +1,9 @@ +package org.readium.r2.testapp.bookshelf + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun BookshelfScreen() { + Text(text = "Bookshelf Screen") +} 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..199dda7a2d --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedScreen.kt @@ -0,0 +1,169 @@ +package org.readium.r2.testapp.catalogs + +import androidx.compose.foundation.clickable +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.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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +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.R +import org.readium.r2.testapp.data.model.Catalog + +@Composable +fun CatalogFeedScreen( + viewModel: CatalogFeedListViewModel = viewModel(), + navController: NavController +) { + val catalogs by viewModel.catalogs.collectAsStateWithLifecycle(initialValue = emptyList()) + var showAddCatalogDialog by remember { mutableStateOf(false) } + + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { showAddCatalogDialog = true } + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_catalog) + ) + } + } + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + items(catalogs) { catalog -> + CatalogItem( + catalog = catalog, + onDelete = { catalogId -> + viewModel.deleteCatalog(catalogId) + }, + onClick = { + navController.currentBackStackEntry?.savedStateHandle?.set("catalog", catalog) + navController.navigate("catalog_detail") + } + ) + HorizontalDivider() + } + } + } + + if (showAddCatalogDialog) { + AddCatalogDialog( + onDismiss = { showAddCatalogDialog = false }, + onConfirm = { title, url -> + viewModel.parseCatalog(title, url) + 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..7fac45e76f --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogScreen.kt @@ -0,0 +1,282 @@ +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.layout.width +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.Card +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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +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.testapp.Screen +import org.readium.r2.testapp.data.model.Catalog + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatalogScreen( + catalog: Catalog, + navController: NavController, + viewModel: CatalogViewModel = viewModel(), + onFacetClick: (facet: Facet) -> Unit, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(catalog) { + viewModel.parseCatalog(catalog) + } + + val navigateToLink = { link: 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) + } + + val navigateToPublication = { publication: Publication -> + navController.currentBackStackEntry?.savedStateHandle?.set("publication", publication) + navController.navigate("publication") + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(catalog.title) }, + actions = { + if (state is CatalogUiState.Success && (state as CatalogUiState.Success).parseData.feed?.facets?.isNotEmpty() == true) { + FacetMenu(facets = (state as CatalogUiState.Success).parseData.feed!!.facets, onFacetClick = onFacetClick) + } + } + ) + } + ) { padding -> + when (val currentState = state) { + is CatalogUiState.Loading -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + + is CatalogUiState.Success -> { + val feed = currentState.parseData.feed + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + if (feed?.navigation?.isNotEmpty() == true) { + item { + NavigationSection( + links = feed.navigation, + onNavigationLinkClick = navigateToLink + ) + } + } + + 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 { navigateToLink(it) } + } + ) + } + } + } + + 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 -> + PublicationItem(publication, 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 -> + PublicationItem(publication, onClick = { onPublicationClick(publication) }) + } + } + } +} + +@Composable +private fun PublicationItem(publication: Publication, onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = Modifier.width(120.dp) + ) { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(publication.links.firstOrNull { it.rels.contains("http://opds-spec.org/image/thumbnail") }?.href) + .crossfade(true) + .build(), + contentDescription = "Publication cover", + contentScale = ContentScale.Crop, + modifier = Modifier + .height(160.dp) + .fillMaxWidth() + ) + publication.metadata.title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(8.dp) + ) + } + } + } +} 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..29cfd3c775 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,18 @@ 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() - lateinit var publication: Publication +// 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 +45,29 @@ 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 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..c27330aa5f --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationScreen.kt @@ -0,0 +1,118 @@ +package org.readium.r2.testapp.catalogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.testapp.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PublicationScreen( + publication: Publication, + viewModel: CatalogViewModel = viewModel() +) { + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = publication.metadata.title ?: "", + maxLines = 1 + ) + } + ) + } + ) { padding -> + PublicationDetailContent( + padding = padding, + publication = publication, + onDownloadClick = { viewModel.downloadPublication(publication) } + ) + } +} + +@Composable +private fun PublicationDetailContent( + padding: PaddingValues, + publication: Publication, + onDownloadClick: () -> Unit +) { + Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(publication.links.firstOrNull { it.rels.contains("cover") }?.href) + .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/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_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 @@ - - - - - - - - - - - - - - - -