diff --git a/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml b/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml index 94623f25a..30813d54f 100644 --- a/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml +++ b/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml @@ -21,7 +21,7 @@ onFlowComplete: - runFlow: "subflows/school_admin_login_flow.yaml" - assertVisible: id: "app_title" - text: "Apps" + text: "Home" - tapOn: "People" - tapOn: id: "ExpandableFab" # +Person button @@ -98,8 +98,8 @@ onFlowComplete: - tapOn: "Sign-up" - assertVisible: id: "app_title" - text: "Apps" -- assertVisible: "Apps" + text: "Home" +- assertVisible: "Home" - assertVisible: "Assignments" - assertVisible: "People" - runFlow: @@ -190,7 +190,7 @@ onFlowComplete: id : "password" - inputText: "test123" - tapOn: "Login" -- assertVisible: "Apps" +- assertVisible: "Home" - tapOn: "Classes" - assertVisible: id: "app_title" diff --git a/.maestro/flows/001_002_add_user_direct_test.yaml b/.maestro/flows/001_002_add_user_direct_test.yaml index 233225b13..d69451abb 100644 --- a/.maestro/flows/001_002_add_user_direct_test.yaml +++ b/.maestro/flows/001_002_add_user_direct_test.yaml @@ -363,7 +363,7 @@ onFlowComplete: - assertVisible: id: "app_title" text: "Assignments" -- assertVisible: "Apps" +- assertVisible: "Home" - assertNotVisible: "Classes" - assertNotVisible: "People" @@ -382,10 +382,10 @@ onFlowComplete: - tapOn: "Url" - inputText: ${output.SCHOOL_URL}respect_school_link/personqrbadge/id/12312 - tapOn: "OK" -- assertVisible: "Apps" +- assertVisible: "Home" - assertVisible: id: "app_title" - text: "Apps" + text: "Home" - assertVisible: "Assignments" - assertVisible: "Classes" - assertVisible: "People" \ No newline at end of file diff --git a/.maestro/flows/001_003_login_using_school_link_test.yaml b/.maestro/flows/001_003_login_using_school_link_test.yaml index 53551a1e6..0559c7f62 100644 --- a/.maestro/flows/001_003_login_using_school_link_test.yaml +++ b/.maestro/flows/001_003_login_using_school_link_test.yaml @@ -48,5 +48,5 @@ onFlowComplete: when: visible: "Save password for Respect?" file: "subflows/save_password_prompt_cancel.yaml" -- assertVisible: "Apps" +- assertVisible: "Home" diff --git a/.maestro/flows/001_005_add_school_self_registration_test.yaml b/.maestro/flows/001_005_add_school_self_registration_test.yaml index 9c67b6774..858b88e6d 100644 --- a/.maestro/flows/001_005_add_school_self_registration_test.yaml +++ b/.maestro/flows/001_005_add_school_self_registration_test.yaml @@ -84,7 +84,7 @@ onFlowComplete: - tapOn: "Sign-up" - assertVisible: id: "app_title" - text: "Apps" + text: "Home" - tapOn: id: "user_account_icon" - assertVisible: "Profile" @@ -106,4 +106,4 @@ onFlowComplete: when: visible: "Save password for Respect?" file: "subflows/save_password_prompt_cancel.yaml" -- assertVisible: "Apps" \ No newline at end of file +- assertVisible: "Home" \ No newline at end of file diff --git a/.maestro/flows/002_browse_lessons_test.yaml b/.maestro/flows/002_browse_lessons_test.yaml index ccfc227b5..fd9afe15c 100644 --- a/.maestro/flows/002_browse_lessons_test.yaml +++ b/.maestro/flows/002_browse_lessons_test.yaml @@ -17,8 +17,13 @@ onFlowComplete: file: "scripts/teardown.js" --- - runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: "Home" +- assertVisible: + id: "app_title" + text: "Home" +- tapOn: "Apps" - tapOn: - id: "floating_action_button" + id: "floating_action_button" # +Add App - tapOn: "Add from Link" - tapOn: "Link*" - inputText: ${output.SCHOOL_URL}static-resources/respect-ds/case_valid/appmanifest.json @@ -30,23 +35,48 @@ onFlowComplete: - assertVisible: "Add App" # verify App got added to Apps section - tapOn: "Add App" -- tapOn: "Apps" +- tapOn: "Home" - assertVisible: id: "app_title" - text: "Apps" -- assertVisible: "My app" + text: "Home" +- tapOn: "Bookmarks" +- assertVisible: "No bookmarks yet" +- assertVisible: "Bookmark a lesson or a playlist to see it here." +- tapOn: "Apps" - tapOn: "My app" -- assertVisible: "Lessons" -- tapOn: "Lessons" - tapOn: "Grade 1" - tapOn: "Lesson 001" - assertVisible: "Lesson 001" -- assertVisible: "App name" +- assertVisible: "My App" - tapOn: "Open" - extendedWaitUntil: visible: "Lesson 001" timeout: 1000 - assertVisible: "Hello World Lesson" - tapOn: "Close" +- assertVisible: "Download" +- assertVisible: "Assign" +- tapOn: "Bookmark" +- back +- assertVisible: "Lesson 002" +- tapOn: "Lesson 002" +- tapOn: "Bookmark" +- tapOn: "Home" +- tapOn: "Bookmarks" +- assertVisible: "Lesson 001" +- assertVisible: "Lesson 002" - +- clearState: world.respect.app +- runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: "Home" +- assertVisible: + id: "app_title" + text: "Home" +- tapOn: "Bookmarks" +- assertVisible: "Lesson 001" +- assertVisible: "Lesson 002" +- tapOn: #Remove bookmark + text: "Bookmark" + index: 0 +- assertNotVisible: "Lesson 001" +- assertVisible: "Lesson 002" diff --git a/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml b/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml index 163d04183..c44e543eb 100644 --- a/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml +++ b/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml @@ -41,7 +41,7 @@ onFlowComplete: when: visible: "Save password for Respect?" file: "subflows/save_password_prompt_cancel.yaml" -- assertVisible: "Apps" +- assertVisible: "Home" - tapOn: "Classes" - assertVisible: id: "app_title" @@ -68,8 +68,6 @@ onFlowComplete: file: "scripts/setDate.js" - inputText: ${output.currentTime} - tapOn: "Lesson/assessment" -- assertVisible: - id: "app_title" - tapOn: "My app" - tapOn: "Grade 1" - tapOn: "Lesson 001" diff --git a/.maestro/flows/subflows/admin_add_app.yaml b/.maestro/flows/subflows/admin_add_app.yaml index e5a4be4be..d79e10ebb 100644 --- a/.maestro/flows/subflows/admin_add_app.yaml +++ b/.maestro/flows/subflows/admin_add_app.yaml @@ -3,7 +3,8 @@ appId: world.respect.app --- - assertVisible: id: "app_title" - text: "Apps" + text: "Home" +- tapOn: "Apps" - tapOn: id: "floating_action_button" - tapOn: "Add from Link" diff --git a/.maestro/flows/subflows/admin_add_app_and_teacher.yaml b/.maestro/flows/subflows/admin_add_app_and_teacher.yaml index 0fa24bc63..deffbcb99 100644 --- a/.maestro/flows/subflows/admin_add_app_and_teacher.yaml +++ b/.maestro/flows/subflows/admin_add_app_and_teacher.yaml @@ -5,7 +5,8 @@ appId: world.respect.app - runFlow: "school_admin_login_flow.yaml" - assertVisible: id: "app_title" - text: "Apps" + text: "Home" +- tapOn: "Apps" - tapOn: id: "floating_action_button" - tapOn: "Add from Link" @@ -16,7 +17,7 @@ appId: world.respect.app id: "app_title" text: "App detail" - tapOn: "Add App" -- tapOn: "Apps" +- tapOn: "Home" - assertVisible: "My app" # Admin add new class diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties deleted file mode 100644 index 05253f1c6..000000000 --- a/gradle/gradle-daemon-jvm.properties +++ /dev/null @@ -1,13 +0,0 @@ -#This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/658299a896470fbb3103ba3a430ee227/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ac151d55def6b6a9a159dc4cb4642851/redirect -toolchainVendor=JETBRAINS -toolchainVersion=21 diff --git a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt index 9203819a4..ec5f1200f 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt @@ -245,6 +245,7 @@ import world.respect.shared.domain.urltonavcommand.ResolveUrlToNavCommandUseCase import world.respect.shared.viewmodel.scanqrcode.ScanQRCodeViewModel import world.respect.shared.domain.navigation.deferreddeeplink.GetDeferredDeepLinkUseCaseAndroid import world.respect.shared.domain.navigation.onappstart.NavigateOnAppStartUseCase +import world.respect.shared.viewmodel.bookmark.BookmarkListViewModel const val SHARED_PREF_SETTINGS_NAME = "respect_settings3_" @@ -384,7 +385,7 @@ val appKoinModule = module { viewModelOf(::EnrollmentEditViewModel) viewModelOf(::InviteQrViewModel) viewModelOf(::CreateAccountSetPasswordViewModel) - + viewModelOf(::BookmarkListViewModel) single { GetOfflineStorageOptionsUseCaseAndroid( diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt index 58ea4bf0b..7c6be4194 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt @@ -13,8 +13,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -46,7 +48,7 @@ import world.respect.navigation.NavCommandEffect import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.biometric.BiometricAuthUseCase import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.apps +import world.respect.shared.generated.resources.home import world.respect.shared.generated.resources.assignments import world.respect.shared.generated.resources.parents_only import world.respect.shared.generated.resources.cancel @@ -60,8 +62,7 @@ import world.respect.shared.navigation.Home import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.PersonList import world.respect.shared.navigation.RespectComposeNavController -import world.respect.shared.resources.StringResourceUiText -import world.respect.shared.resources.StringUiText +import world.respect.shared.ext.asString import world.respect.shared.viewmodel.app.appstate.AppUiState import world.respect.shared.viewmodel.app.appstate.FabUiState import world.respect.shared.viewmodel.app.appstate.SnackBarFlowDispatcher @@ -84,7 +85,7 @@ val APP_TOP_LEVEL_NAV_ITEMS = listOf( TopNavigationItem( destRoute = Home, icon = Icons.Filled.GridView, - label = Res.string.apps, + label = Res.string.home, routeName = "$routeNamePrefix.Home", ), TopNavigationItem( @@ -116,7 +117,7 @@ val APP_TOP_LEVEL_NAV_ITEMS_FOR_CHILD = listOf( TopNavigationItem( destRoute = Home, icon = Icons.Filled.GridView, - label = Res.string.apps, + label = Res.string.home, routeName = "$routeNamePrefix.Home", ), ) @@ -177,17 +178,19 @@ fun App( val koin = getKoin() LaunchedEffect(Unit) { - koin.get().snackFlow.collectLatest { - val uiText = it.message - val message = if(uiText is StringUiText) { - uiText.text - }else if(uiText is StringResourceUiText) { - getString(uiText.resource) - }else { - "" - } + koin.get().snackFlow.collectLatest { snack-> + + val message = snack.message.asString() + val actionLabel = snack.action?.asString() - snackbarHostState.showSnackbar(message, it.action) + val result = snackbarHostState.showSnackbar( + message = message, + actionLabel = actionLabel, + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + snack.onAction?.invoke() + } } } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt index ad87b7675..e80b20ae4 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt @@ -14,6 +14,7 @@ import world.respect.app.view.apps.list.AppListScreen import world.respect.app.view.assignment.detail.AssignmentDetailScreen import world.respect.app.view.assignment.edit.AssignmentEditScreen import world.respect.app.view.assignment.list.AssignmentListScreen +import world.respect.app.view.bookmark.BookmarkListScreen import world.respect.app.view.clazz.detail.ClazzDetailScreen import world.respect.app.view.clazz.edit.ClazzEditScreen import world.respect.app.view.clazz.list.ClazzListScreen @@ -73,6 +74,7 @@ import world.respect.shared.navigation.ClazzDetail import world.respect.shared.navigation.ClazzEdit import world.respect.shared.navigation.ClazzList import world.respect.shared.navigation.AcceptInvite +import world.respect.shared.navigation.BookmarkList import world.respect.shared.navigation.CopyCode import world.respect.shared.navigation.CreateAccount import world.respect.shared.navigation.CreateAccountSetPassword @@ -260,6 +262,14 @@ fun AppNavHost( ) } + composable { + BookmarkListScreen( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) + ) + } composable { val viewModel: ClazzListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/bookmark/BookmarkListScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/bookmark/BookmarkListScreen.kt new file mode 100644 index 000000000..53aa18e6c --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/bookmark/BookmarkListScreen.kt @@ -0,0 +1,217 @@ +package world.respect.app.view.bookmark + +import androidx.compose.foundation.Image +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material3.ListItem +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import world.respect.app.app.RespectAsyncImage +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.model.Bookmark +import world.respect.datalayer.school.model.BookmarkDetails +import world.respect.lib.opds.model.findIcons +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.bookmark +import world.respect.shared.generated.resources.msg_see_bookmark +import world.respect.shared.generated.resources.no_bookmark +import world.respect.shared.viewmodel.app.appstate.getTitle +import world.respect.shared.viewmodel.bookmark.BookmarkListUiState +import world.respect.shared.viewmodel.bookmark.BookmarkListViewModel + +@Composable +fun BookmarkListScreen( + viewModel: BookmarkListViewModel +) { + val uiState by viewModel.uiState.collectAsState() + + BookmarkListScreen( + uiState = uiState, + onClickRemoveBookmark = viewModel::onClickRemoveBookmark, + onClickBookmark = viewModel::onClickBookmark + ) +} + +@Composable +fun BookmarkListScreen( + uiState: BookmarkListUiState, + onClickRemoveBookmark: (Bookmark) -> Unit, + onClickBookmark: (Bookmark) -> Unit +) { + + when { + uiState.bookmarkDetails.isEmpty() -> { + EmptyBookmarkState() + } + + else -> { + BookmarkListContent( + uiState.bookmarkDetails, + onClickRemoveBookmark, + onClickBookmark + ) + } + } +} + + +@Composable +private fun EmptyBookmarkState() { + Box( + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(Res.drawable.no_bookmark), + contentDescription = stringResource(resource = Res.string.no_bookmark), + contentScale = ContentScale.Fit, + modifier = Modifier.size(200.dp) + ) + Text( + text = stringResource(Res.string.no_bookmark), + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(Res.string.msg_see_bookmark), + modifier = Modifier.padding(bottom = 64.dp) + ) + } + } +} + + +@Composable +private fun BookmarkListContent( + bookmarkDetails: List, + onClickRemoveBookmark: (Bookmark) -> Unit, + onClickBookmark: (Bookmark) -> Unit +) { + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(bookmarkDetails) { bookmarkDetails -> + ListItem( + modifier = Modifier.fillMaxWidth() + .clickable { + onClickBookmark(bookmarkDetails.bookmark) + }, + + leadingContent = { + + Box( + modifier = Modifier + .fillMaxHeight() + .width(48.dp), + contentAlignment = Alignment.Center + ) { + RespectAsyncImage( + uri = bookmarkDetails.bookmark.imageUrl.toString(), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + ) + } + + }, + + headlineContent = { + Text( + text = bookmarkDetails.bookmark.title?.getTitle() ?: "" + ) + }, + + supportingContent = { + Column { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + bookmarkDetails.app?.dataOrNull()?.findIcons()?.firstOrNull() + ?.also { icon -> + RespectAsyncImage( + uri = icon.href, + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(20.dp) + + ) + } + + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = bookmarkDetails.app?.dataOrNull()?.metadata?.title?.getTitle() + .orEmpty() + ) + } + /**Currently there is no data in subtitle**/ + /* Text( + text = bookmark.subTitle?.getTitle() ?: "" + )*/ + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + bookmarkDetails.bookmark.language?.let { + Text(text = it) + } + /**Currently there is no data in grade**/ + /* bookmark.grade?.let { + Text(text = it) + }*/ + + bookmarkDetails.bookmark.type?.let { + Text(text = it) + } + } + + } + }, + + + trailingContent = { + Icon( + modifier = Modifier.clickable { + onClickRemoveBookmark(bookmarkDetails.bookmark) + }, + imageVector = Icons.Default.Bookmark, + contentDescription = stringResource(Res.string.bookmark), + ) + } + ) + } + } +} diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/home/HomeScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/home/HomeScreen.kt index 47caed99a..281e43d52 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/home/HomeScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/home/HomeScreen.kt @@ -15,17 +15,19 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import world.respect.app.view.apps.launcher.AppLauncherScreen -import world.respect.app.view.assignment.list.AssignmentListScreen +import world.respect.app.view.bookmark.BookmarkListScreen import world.respect.app.viewmodel.respectViewModel import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.apps -import world.respect.shared.generated.resources.textbooks +import world.respect.shared.generated.resources.bookmarks import world.respect.shared.navigation.RespectComposeNavController import world.respect.shared.viewmodel.app.appstate.AppUiState import world.respect.shared.viewmodel.apps.launcher.AppLauncherViewModel +import world.respect.shared.viewmodel.bookmark.BookmarkListViewModel enum class HomeScreenTabs(val label: StringResource) { APPS(Res.string.apps), + BOOKMARK(Res.string.bookmarks) //Temporary example //PLAYLISTS(Res.string.textbooks) @@ -88,6 +90,17 @@ fun HomeScreen( ) ) }*/ + + HomeScreenTabs.BOOKMARK -> { + val viewModel: BookmarkListViewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) + + BookmarkListScreen( + viewModel = viewModel + ) + } } } } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/detail/LearningUnitDetailScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/detail/LearningUnitDetailScreen.kt index ba44292a5..685ca1a06 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/detail/LearningUnitDetailScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/detail/LearningUnitDetailScreen.kt @@ -16,7 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.NearMe import androidx.compose.material3.Button import androidx.compose.material3.Icon @@ -33,11 +33,11 @@ import com.ustadmobile.libuicompose.theme.black import com.ustadmobile.libuicompose.theme.white import org.jetbrains.compose.resources.stringResource import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.app_name import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel import androidx.compose.ui.graphics.vector.ImageVector import world.respect.shared.generated.resources.assign import world.respect.shared.generated.resources.download +import androidx.compose.material.icons.outlined.BookmarkBorder import world.respect.shared.generated.resources.open import world.respect.shared.viewmodel.app.appstate.getTitle import androidx.compose.material3.ListItem @@ -47,6 +47,9 @@ import com.ustadmobile.libcache.PublicationPinState import world.respect.app.app.RespectAsyncImage import world.respect.app.components.RespectOfflineItemStatusIcon import world.respect.app.components.RespectQuickActionButton +import world.respect.datalayer.ext.dataOrNull +import world.respect.lib.opds.model.findIcons +import world.respect.shared.generated.resources.bookmark import world.respect.shared.generated.resources.cancel import world.respect.shared.generated.resources.downloaded import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailUiState @@ -62,15 +65,18 @@ fun LearningUnitDetailScreen( onClickOpen = viewModel::onClickOpen, onClickDownload = viewModel::onClickDownload, onClickAssign = viewModel::onClickAssign, + onClickBookmark = viewModel::onClickBookmark ) } @Composable + fun LearningUnitDetailScreen( uiState: LearningUnitDetailUiState, onClickOpen: () -> Unit, onClickDownload: () -> Unit, onClickAssign: () -> Unit, + onClickBookmark: () -> Unit ) { LazyColumn( @@ -118,17 +124,22 @@ fun LearningUnitDetailScreen( .border(1.dp, black, CircleShape), contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Default.Android, - modifier = Modifier.padding(6.dp), - contentDescription = null - ) + uiState.app.dataOrNull()?.findIcons()?.firstOrNull() + ?.let { icon -> + RespectAsyncImage( + uri = icon.href, + contentDescription = "", + contentScale = ContentScale.Fit, + modifier = Modifier.size(20.dp) + ) + } } Spacer(modifier = Modifier.width(12.dp)) Text( - text = stringResource(Res.string.app_name), + text = uiState.app.dataOrNull()?.metadata?.title?.getTitle() + .orEmpty() ) } @@ -174,6 +185,17 @@ fun LearningUnitDetailScreen( enabled = uiState.buttonsEnabled, ) + RespectQuickActionButton( + imageVector = if (uiState.isBookmarked) { + Icons.Filled.Bookmark + } else { + Icons.Outlined.BookmarkBorder + }, + labelText = stringResource(Res.string.bookmark), + onClick = onClickBookmark, + enabled = uiState.buttonsEnabled, + ) + RespectQuickActionButton( imageVector = Icons.Filled.NearMe, labelText = stringResource(Res.string.assign), diff --git a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/1.json b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/1.json deleted file mode 100644 index 41a972922..000000000 --- a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/1.json +++ /dev/null @@ -1,907 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 1, - "identityHash": "7b13c2a61c374996babfbd6518093fd7", - "entities": [ - { - "tableName": "LangMapEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lmeId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lmeTopParentType` INTEGER NOT NULL, `lmeTopParentUid1` INTEGER NOT NULL, `lmeTopParentUid2` INTEGER NOT NULL, `lmePropType` INTEGER NOT NULL, `lmePropFk` INTEGER NOT NULL, `lmeLang` TEXT NOT NULL, `lmeRegion` TEXT, `lmeValue` TEXT NOT NULL)", - "fields": [ - { - "fieldPath": "lmeId", - "columnName": "lmeId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lmeTopParentType", - "columnName": "lmeTopParentType", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lmeTopParentUid1", - "columnName": "lmeTopParentUid1", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lmeTopParentUid2", - "columnName": "lmeTopParentUid2", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lmePropType", - "columnName": "lmePropType", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lmePropFk", - "columnName": "lmePropFk", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lmeLang", - "columnName": "lmeLang", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lmeRegion", - "columnName": "lmeRegion", - "affinity": "TEXT" - }, - { - "fieldPath": "lmeValue", - "columnName": "lmeValue", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "lmeId" - ] - }, - "indices": [ - { - "name": "index_LangMapEntity_lmeTopParentType_lmeTopParentUid1_lmeTopParentUid2_lmePropType", - "unique": false, - "columnNames": [ - "lmeTopParentType", - "lmeTopParentUid1", - "lmeTopParentUid2", - "lmePropType" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LangMapEntity_lmeTopParentType_lmeTopParentUid1_lmeTopParentUid2_lmePropType` ON `${TABLE_NAME}` (`lmeTopParentType`, `lmeTopParentUid1`, `lmeTopParentUid2`, `lmePropType`)" - } - ] - }, - { - "tableName": "ReadiumLinkEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rleId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rleOpdsParentType` INTEGER NOT NULL, `rleOpdsParentUid` INTEGER NOT NULL, `rlePropType` TEXT NOT NULL, `rlePropFk` INTEGER NOT NULL, `rleIndex` INTEGER NOT NULL, `rleHref` TEXT NOT NULL, `rleRel` TEXT, `rleType` TEXT, `rleTitle` TEXT, `rleTemplated` INTEGER, `rleProperties` TEXT, `rleHeight` INTEGER, `rleWidth` INTEGER, `rleSize` INTEGER, `rleBitrate` REAL, `rleDuration` REAL, `rleLanguage` TEXT)", - "fields": [ - { - "fieldPath": "rleId", - "columnName": "rleId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rleOpdsParentType", - "columnName": "rleOpdsParentType", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rleOpdsParentUid", - "columnName": "rleOpdsParentUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rlePropType", - "columnName": "rlePropType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rlePropFk", - "columnName": "rlePropFk", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rleIndex", - "columnName": "rleIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rleHref", - "columnName": "rleHref", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rleRel", - "columnName": "rleRel", - "affinity": "TEXT" - }, - { - "fieldPath": "rleType", - "columnName": "rleType", - "affinity": "TEXT" - }, - { - "fieldPath": "rleTitle", - "columnName": "rleTitle", - "affinity": "TEXT" - }, - { - "fieldPath": "rleTemplated", - "columnName": "rleTemplated", - "affinity": "INTEGER" - }, - { - "fieldPath": "rleProperties", - "columnName": "rleProperties", - "affinity": "TEXT" - }, - { - "fieldPath": "rleHeight", - "columnName": "rleHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "rleWidth", - "columnName": "rleWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "rleSize", - "columnName": "rleSize", - "affinity": "INTEGER" - }, - { - "fieldPath": "rleBitrate", - "columnName": "rleBitrate", - "affinity": "REAL" - }, - { - "fieldPath": "rleDuration", - "columnName": "rleDuration", - "affinity": "REAL" - }, - { - "fieldPath": "rleLanguage", - "columnName": "rleLanguage", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rleId" - ] - } - }, - { - "tableName": "OpdsPublicationEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`opeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opeOfeUid` INTEGER NOT NULL, `opeOgeUid` INTEGER NOT NULL, `opeIndex` INTEGER NOT NULL, `opeUrl` TEXT, `opeUrlHash` INTEGER NOT NULL, `opeLastModified` INTEGER NOT NULL, `opeEtag` TEXT, `opeMdIdentifier` TEXT, `opeMdLanguage` TEXT, `opeMdType` TEXT, `opeMdDescription` TEXT, `opeMdNumberOfPages` INTEGER, `opeMdDuration` REAL)", - "fields": [ - { - "fieldPath": "opeUid", - "columnName": "opeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opeOfeUid", - "columnName": "opeOfeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opeOgeUid", - "columnName": "opeOgeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opeIndex", - "columnName": "opeIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opeUrl", - "columnName": "opeUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "opeUrlHash", - "columnName": "opeUrlHash", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opeLastModified", - "columnName": "opeLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opeEtag", - "columnName": "opeEtag", - "affinity": "TEXT" - }, - { - "fieldPath": "opeMdIdentifier", - "columnName": "opeMdIdentifier", - "affinity": "TEXT" - }, - { - "fieldPath": "opeMdLanguage", - "columnName": "opeMdLanguage", - "affinity": "TEXT" - }, - { - "fieldPath": "opeMdType", - "columnName": "opeMdType", - "affinity": "TEXT" - }, - { - "fieldPath": "opeMdDescription", - "columnName": "opeMdDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "opeMdNumberOfPages", - "columnName": "opeMdNumberOfPages", - "affinity": "INTEGER" - }, - { - "fieldPath": "opeMdDuration", - "columnName": "opeMdDuration", - "affinity": "REAL" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "opeUid" - ] - } - }, - { - "tableName": "ReadiumSubjectEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rseUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rseStringValue` TEXT, `rseTopParentType` INTEGER NOT NULL, `rseTopParentUid` INTEGER NOT NULL, `rseSubjectSortAs` TEXT, `rseSubjectCode` TEXT, `rseSubjectScheme` TEXT, `rseIndex` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "rseUid", - "columnName": "rseUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rseStringValue", - "columnName": "rseStringValue", - "affinity": "TEXT" - }, - { - "fieldPath": "rseTopParentType", - "columnName": "rseTopParentType", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rseTopParentUid", - "columnName": "rseTopParentUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rseSubjectSortAs", - "columnName": "rseSubjectSortAs", - "affinity": "TEXT" - }, - { - "fieldPath": "rseSubjectCode", - "columnName": "rseSubjectCode", - "affinity": "TEXT" - }, - { - "fieldPath": "rseSubjectScheme", - "columnName": "rseSubjectScheme", - "affinity": "TEXT" - }, - { - "fieldPath": "rseIndex", - "columnName": "rseIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rseUid" - ] - } - }, - { - "tableName": "OpdsFacetEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofaeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ofaeOfeUid` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "ofaeUid", - "columnName": "ofaeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ofaeOfeUid", - "columnName": "ofaeOfeUid", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "ofaeUid" - ] - } - }, - { - "tableName": "OpdsGroupEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ogeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ogeOfeUid` INTEGER NOT NULL, `ogeIndex` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "ogeUid", - "columnName": "ogeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ogeOfeUid", - "columnName": "ogeOfeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ogeIndex", - "columnName": "ogeIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "ogeUid" - ] - } - }, - { - "tableName": "OpdsFeedEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofeUid` INTEGER NOT NULL, `ofeUrl` TEXT NOT NULL, `ofeUrlHash` INTEGER NOT NULL, `ofeLastModifiedHeader` INTEGER NOT NULL, `ofeEtag` TEXT, PRIMARY KEY(`ofeUid`))", - "fields": [ - { - "fieldPath": "ofeUid", - "columnName": "ofeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ofeUrl", - "columnName": "ofeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ofeUrlHash", - "columnName": "ofeUrlHash", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ofeLastModifiedHeader", - "columnName": "ofeLastModifiedHeader", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ofeEtag", - "columnName": "ofeEtag", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "ofeUid" - ] - } - }, - { - "tableName": "OpdsFeedMetadataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofmeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ofmeOfeUid` INTEGER NOT NULL, `ofmePropType` INTEGER NOT NULL, `ofmePropFk` INTEGER NOT NULL, `ofmeIdentifier` TEXT, `ofmeType` TEXT, `ofmeTitle` TEXT NOT NULL, `ofmeSubtitle` TEXT, `ofmeModified` TEXT, `ofmeDescription` TEXT, `ofmeItemsPerPage` INTEGER, `ofmeCurrentPage` INTEGER, `ofmeNumberOfItems` INTEGER)", - "fields": [ - { - "fieldPath": "ofmeUid", - "columnName": "ofmeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ofmeOfeUid", - "columnName": "ofmeOfeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ofmePropType", - "columnName": "ofmePropType", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ofmePropFk", - "columnName": "ofmePropFk", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ofmeIdentifier", - "columnName": "ofmeIdentifier", - "affinity": "TEXT" - }, - { - "fieldPath": "ofmeType", - "columnName": "ofmeType", - "affinity": "TEXT" - }, - { - "fieldPath": "ofmeTitle", - "columnName": "ofmeTitle", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ofmeSubtitle", - "columnName": "ofmeSubtitle", - "affinity": "TEXT" - }, - { - "fieldPath": "ofmeModified", - "columnName": "ofmeModified", - "affinity": "TEXT" - }, - { - "fieldPath": "ofmeDescription", - "columnName": "ofmeDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "ofmeItemsPerPage", - "columnName": "ofmeItemsPerPage", - "affinity": "INTEGER" - }, - { - "fieldPath": "ofmeCurrentPage", - "columnName": "ofmeCurrentPage", - "affinity": "INTEGER" - }, - { - "fieldPath": "ofmeNumberOfItems", - "columnName": "ofmeNumberOfItems", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "ofmeUid" - ] - } - }, - { - "tableName": "CompatibleAppEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`caeUid` INTEGER NOT NULL, `caeUrl` TEXT NOT NULL, `caeIcon` TEXT, `caeLastModified` INTEGER NOT NULL, `caeEtag` TEXT, `caeLicense` TEXT NOT NULL, `caeWebsite` TEXT NOT NULL, `caeLearningUnits` TEXT NOT NULL, `caeDefaultLaunchUri` TEXT NOT NULL, `caeAndroidPackageId` TEXT, `caeAndroidStoreList` TEXT, `caeAndroidSourceCode` TEXT, PRIMARY KEY(`caeUid`))", - "fields": [ - { - "fieldPath": "caeUid", - "columnName": "caeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "caeUrl", - "columnName": "caeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeIcon", - "columnName": "caeIcon", - "affinity": "TEXT" - }, - { - "fieldPath": "caeLastModified", - "columnName": "caeLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "caeEtag", - "columnName": "caeEtag", - "affinity": "TEXT" - }, - { - "fieldPath": "caeLicense", - "columnName": "caeLicense", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeWebsite", - "columnName": "caeWebsite", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeLearningUnits", - "columnName": "caeLearningUnits", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeDefaultLaunchUri", - "columnName": "caeDefaultLaunchUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeAndroidPackageId", - "columnName": "caeAndroidPackageId", - "affinity": "TEXT" - }, - { - "fieldPath": "caeAndroidStoreList", - "columnName": "caeAndroidStoreList", - "affinity": "TEXT" - }, - { - "fieldPath": "caeAndroidSourceCode", - "columnName": "caeAndroidSourceCode", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "caeUid" - ] - } - }, - { - "tableName": "CompatibleAppAddJoin", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appCaeUid` INTEGER NOT NULL, `added` INTEGER NOT NULL, PRIMARY KEY(`appCaeUid`))", - "fields": [ - { - "fieldPath": "appCaeUid", - "columnName": "appCaeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "added", - "columnName": "added", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "appCaeUid" - ] - } - }, - { - "tableName": "PersonPasskeyEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`personPasskeyUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ppPersonUid` INTEGER NOT NULL, `ppAttestationObj` TEXT, `ppClientDataJson` TEXT, `ppOriginString` TEXT, `ppId` TEXT, `ppChallengeString` TEXT, `ppPublicKey` TEXT, `isRevoked` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "personPasskeyUid", - "columnName": "personPasskeyUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ppPersonUid", - "columnName": "ppPersonUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ppAttestationObj", - "columnName": "ppAttestationObj", - "affinity": "TEXT" - }, - { - "fieldPath": "ppClientDataJson", - "columnName": "ppClientDataJson", - "affinity": "TEXT" - }, - { - "fieldPath": "ppOriginString", - "columnName": "ppOriginString", - "affinity": "TEXT" - }, - { - "fieldPath": "ppId", - "columnName": "ppId", - "affinity": "TEXT" - }, - { - "fieldPath": "ppChallengeString", - "columnName": "ppChallengeString", - "affinity": "TEXT" - }, - { - "fieldPath": "ppPublicKey", - "columnName": "ppPublicKey", - "affinity": "TEXT" - }, - { - "fieldPath": "isRevoked", - "columnName": "isRevoked", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "personPasskeyUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rdUid` INTEGER NOT NULL, `rdUrl` TEXT NOT NULL, `rdInvitePrefix` TEXT NOT NULL, PRIMARY KEY(`rdUid`))", - "fields": [ - { - "fieldPath": "rdUid", - "columnName": "rdUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rdUrl", - "columnName": "rdUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rdInvitePrefix", - "columnName": "rdInvitePrefix", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "rdUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntryEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`reUid` INTEGER NOT NULL, `reSelf` TEXT NOT NULL, `reXapi` TEXT NOT NULL, `reOneRoster` TEXT NOT NULL, `reRespectExt` TEXT, `reRpId` TEXT, `reSchoolCode` TEXT, `reDirectoryCode` TEXT, `reLastModified` INTEGER NOT NULL, `reStored` INTEGER NOT NULL, PRIMARY KEY(`reUid`))", - "fields": [ - { - "fieldPath": "reUid", - "columnName": "reUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "reSelf", - "columnName": "reSelf", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reXapi", - "columnName": "reXapi", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reOneRoster", - "columnName": "reOneRoster", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reRespectExt", - "columnName": "reRespectExt", - "affinity": "TEXT" - }, - { - "fieldPath": "reRpId", - "columnName": "reRpId", - "affinity": "TEXT" - }, - { - "fieldPath": "reSchoolCode", - "columnName": "reSchoolCode", - "affinity": "TEXT" - }, - { - "fieldPath": "reDirectoryCode", - "columnName": "reDirectoryCode", - "affinity": "TEXT" - }, - { - "fieldPath": "reLastModified", - "columnName": "reLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "reStored", - "columnName": "reStored", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "reUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntryLangMapEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sdelUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sdelReUid` INTEGER NOT NULL, `sdelLang` TEXT NOT NULL, `sdelRegion` TEXT, `sdelValue` TEXT NOT NULL, `sdelPropId` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "sdelUid", - "columnName": "sdelUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sdelReUid", - "columnName": "sdelReUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sdelLang", - "columnName": "sdelLang", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sdelRegion", - "columnName": "sdelRegion", - "affinity": "TEXT" - }, - { - "fieldPath": "sdelValue", - "columnName": "sdelValue", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sdelPropId", - "columnName": "sdelPropId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "sdelUid" - ] - } - }, - { - "tableName": "SchoolConfigEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rcUid` INTEGER NOT NULL, `dbUrl` TEXT NOT NULL, PRIMARY KEY(`rcUid`))", - "fields": [ - { - "fieldPath": "rcUid", - "columnName": "rcUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dbUrl", - "columnName": "dbUrl", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "rcUid" - ] - } - }, - { - "tableName": "NetworkValidationInfoEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nviUrlHash` INTEGER NOT NULL, `nviKey` INTEGER NOT NULL, `nviVaryHeader` TEXT, `nviLastModified` INTEGER NOT NULL, `nviEtag` TEXT, `nviConsistentThrough` INTEGER NOT NULL, `nviLastChecked` INTEGER NOT NULL, PRIMARY KEY(`nviUrlHash`, `nviKey`))", - "fields": [ - { - "fieldPath": "nviUrlHash", - "columnName": "nviUrlHash", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviKey", - "columnName": "nviKey", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviVaryHeader", - "columnName": "nviVaryHeader", - "affinity": "TEXT" - }, - { - "fieldPath": "nviLastModified", - "columnName": "nviLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviEtag", - "columnName": "nviEtag", - "affinity": "TEXT" - }, - { - "fieldPath": "nviConsistentThrough", - "columnName": "nviConsistentThrough", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviLastChecked", - "columnName": "nviLastChecked", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "nviUrlHash", - "nviKey" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7b13c2a61c374996babfbd6518093fd7')" - ] - } -} \ No newline at end of file diff --git a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/2.json b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/2.json deleted file mode 100644 index 4235f0391..000000000 --- a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/2.json +++ /dev/null @@ -1,327 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "1420dddb863a361cd3cf0b7fdd8d2732", - "entities": [ - { - "tableName": "CompatibleAppEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`caeUid` INTEGER NOT NULL, `caeUrl` TEXT NOT NULL, `caeIcon` TEXT, `caeLastModified` INTEGER NOT NULL, `caeEtag` TEXT, `caeLicense` TEXT NOT NULL, `caeWebsite` TEXT NOT NULL, `caeLearningUnits` TEXT NOT NULL, `caeDefaultLaunchUri` TEXT NOT NULL, `caeAndroidPackageId` TEXT, `caeAndroidStoreList` TEXT, `caeAndroidSourceCode` TEXT, PRIMARY KEY(`caeUid`))", - "fields": [ - { - "fieldPath": "caeUid", - "columnName": "caeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "caeUrl", - "columnName": "caeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeIcon", - "columnName": "caeIcon", - "affinity": "TEXT" - }, - { - "fieldPath": "caeLastModified", - "columnName": "caeLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "caeEtag", - "columnName": "caeEtag", - "affinity": "TEXT" - }, - { - "fieldPath": "caeLicense", - "columnName": "caeLicense", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeWebsite", - "columnName": "caeWebsite", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeLearningUnits", - "columnName": "caeLearningUnits", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeDefaultLaunchUri", - "columnName": "caeDefaultLaunchUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeAndroidPackageId", - "columnName": "caeAndroidPackageId", - "affinity": "TEXT" - }, - { - "fieldPath": "caeAndroidStoreList", - "columnName": "caeAndroidStoreList", - "affinity": "TEXT" - }, - { - "fieldPath": "caeAndroidSourceCode", - "columnName": "caeAndroidSourceCode", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "caeUid" - ] - } - }, - { - "tableName": "CompatibleAppAddJoin", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appCaeUid` INTEGER NOT NULL, `added` INTEGER NOT NULL, PRIMARY KEY(`appCaeUid`))", - "fields": [ - { - "fieldPath": "appCaeUid", - "columnName": "appCaeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "added", - "columnName": "added", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "appCaeUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rdUid` INTEGER NOT NULL, `rdUrl` TEXT NOT NULL, `rdInvitePrefix` TEXT NOT NULL, PRIMARY KEY(`rdUid`))", - "fields": [ - { - "fieldPath": "rdUid", - "columnName": "rdUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rdUrl", - "columnName": "rdUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rdInvitePrefix", - "columnName": "rdInvitePrefix", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "rdUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntryEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`reUid` INTEGER NOT NULL, `reSelf` TEXT NOT NULL, `reXapi` TEXT NOT NULL, `reOneRoster` TEXT NOT NULL, `reRespectExt` TEXT, `reRpId` TEXT, `reLastModified` INTEGER NOT NULL, `reStored` INTEGER NOT NULL, PRIMARY KEY(`reUid`))", - "fields": [ - { - "fieldPath": "reUid", - "columnName": "reUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "reSelf", - "columnName": "reSelf", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reXapi", - "columnName": "reXapi", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reOneRoster", - "columnName": "reOneRoster", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reRespectExt", - "columnName": "reRespectExt", - "affinity": "TEXT" - }, - { - "fieldPath": "reRpId", - "columnName": "reRpId", - "affinity": "TEXT" - }, - { - "fieldPath": "reLastModified", - "columnName": "reLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "reStored", - "columnName": "reStored", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "reUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntryLangMapEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sdelUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sdelReUid` INTEGER NOT NULL, `sdelLang` TEXT NOT NULL, `sdelRegion` TEXT, `sdelValue` TEXT NOT NULL, `sdelPropId` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "sdelUid", - "columnName": "sdelUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sdelReUid", - "columnName": "sdelReUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sdelLang", - "columnName": "sdelLang", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sdelRegion", - "columnName": "sdelRegion", - "affinity": "TEXT" - }, - { - "fieldPath": "sdelValue", - "columnName": "sdelValue", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sdelPropId", - "columnName": "sdelPropId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "sdelUid" - ] - } - }, - { - "tableName": "SchoolConfigEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rcUid` INTEGER NOT NULL, `dbUrl` TEXT NOT NULL, PRIMARY KEY(`rcUid`))", - "fields": [ - { - "fieldPath": "rcUid", - "columnName": "rcUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dbUrl", - "columnName": "dbUrl", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "rcUid" - ] - } - }, - { - "tableName": "NetworkValidationInfoEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nviUrlHash` INTEGER NOT NULL, `nviKey` INTEGER NOT NULL, `nviVaryHeader` TEXT, `nviLastModified` INTEGER NOT NULL, `nviEtag` TEXT, `nviConsistentThrough` INTEGER, `nviLastChecked` INTEGER NOT NULL, PRIMARY KEY(`nviUrlHash`, `nviKey`))", - "fields": [ - { - "fieldPath": "nviUrlHash", - "columnName": "nviUrlHash", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviKey", - "columnName": "nviKey", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviVaryHeader", - "columnName": "nviVaryHeader", - "affinity": "TEXT" - }, - { - "fieldPath": "nviLastModified", - "columnName": "nviLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviEtag", - "columnName": "nviEtag", - "affinity": "TEXT" - }, - { - "fieldPath": "nviConsistentThrough", - "columnName": "nviConsistentThrough", - "affinity": "INTEGER" - }, - { - "fieldPath": "nviLastChecked", - "columnName": "nviLastChecked", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "nviUrlHash", - "nviKey" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1420dddb863a361cd3cf0b7fdd8d2732')" - ] - } -} \ No newline at end of file diff --git a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/3.json b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/3.json deleted file mode 100644 index c8b30924c..000000000 --- a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/3.json +++ /dev/null @@ -1,327 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 3, - "identityHash": "1420dddb863a361cd3cf0b7fdd8d2732", - "entities": [ - { - "tableName": "CompatibleAppEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`caeUid` INTEGER NOT NULL, `caeUrl` TEXT NOT NULL, `caeIcon` TEXT, `caeLastModified` INTEGER NOT NULL, `caeEtag` TEXT, `caeLicense` TEXT NOT NULL, `caeWebsite` TEXT NOT NULL, `caeLearningUnits` TEXT NOT NULL, `caeDefaultLaunchUri` TEXT NOT NULL, `caeAndroidPackageId` TEXT, `caeAndroidStoreList` TEXT, `caeAndroidSourceCode` TEXT, PRIMARY KEY(`caeUid`))", - "fields": [ - { - "fieldPath": "caeUid", - "columnName": "caeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "caeUrl", - "columnName": "caeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeIcon", - "columnName": "caeIcon", - "affinity": "TEXT" - }, - { - "fieldPath": "caeLastModified", - "columnName": "caeLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "caeEtag", - "columnName": "caeEtag", - "affinity": "TEXT" - }, - { - "fieldPath": "caeLicense", - "columnName": "caeLicense", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeWebsite", - "columnName": "caeWebsite", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeLearningUnits", - "columnName": "caeLearningUnits", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeDefaultLaunchUri", - "columnName": "caeDefaultLaunchUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "caeAndroidPackageId", - "columnName": "caeAndroidPackageId", - "affinity": "TEXT" - }, - { - "fieldPath": "caeAndroidStoreList", - "columnName": "caeAndroidStoreList", - "affinity": "TEXT" - }, - { - "fieldPath": "caeAndroidSourceCode", - "columnName": "caeAndroidSourceCode", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "caeUid" - ] - } - }, - { - "tableName": "CompatibleAppAddJoin", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appCaeUid` INTEGER NOT NULL, `added` INTEGER NOT NULL, PRIMARY KEY(`appCaeUid`))", - "fields": [ - { - "fieldPath": "appCaeUid", - "columnName": "appCaeUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "added", - "columnName": "added", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "appCaeUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rdUid` INTEGER NOT NULL, `rdUrl` TEXT NOT NULL, `rdInvitePrefix` TEXT NOT NULL, PRIMARY KEY(`rdUid`))", - "fields": [ - { - "fieldPath": "rdUid", - "columnName": "rdUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rdUrl", - "columnName": "rdUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rdInvitePrefix", - "columnName": "rdInvitePrefix", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "rdUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntryEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`reUid` INTEGER NOT NULL, `reSelf` TEXT NOT NULL, `reXapi` TEXT NOT NULL, `reOneRoster` TEXT NOT NULL, `reRespectExt` TEXT, `reRpId` TEXT, `reLastModified` INTEGER NOT NULL, `reStored` INTEGER NOT NULL, PRIMARY KEY(`reUid`))", - "fields": [ - { - "fieldPath": "reUid", - "columnName": "reUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "reSelf", - "columnName": "reSelf", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reXapi", - "columnName": "reXapi", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reOneRoster", - "columnName": "reOneRoster", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reRespectExt", - "columnName": "reRespectExt", - "affinity": "TEXT" - }, - { - "fieldPath": "reRpId", - "columnName": "reRpId", - "affinity": "TEXT" - }, - { - "fieldPath": "reLastModified", - "columnName": "reLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "reStored", - "columnName": "reStored", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "reUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntryLangMapEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sdelUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sdelReUid` INTEGER NOT NULL, `sdelLang` TEXT NOT NULL, `sdelRegion` TEXT, `sdelValue` TEXT NOT NULL, `sdelPropId` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "sdelUid", - "columnName": "sdelUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sdelReUid", - "columnName": "sdelReUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sdelLang", - "columnName": "sdelLang", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sdelRegion", - "columnName": "sdelRegion", - "affinity": "TEXT" - }, - { - "fieldPath": "sdelValue", - "columnName": "sdelValue", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sdelPropId", - "columnName": "sdelPropId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "sdelUid" - ] - } - }, - { - "tableName": "SchoolConfigEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rcUid` INTEGER NOT NULL, `dbUrl` TEXT NOT NULL, PRIMARY KEY(`rcUid`))", - "fields": [ - { - "fieldPath": "rcUid", - "columnName": "rcUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dbUrl", - "columnName": "dbUrl", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "rcUid" - ] - } - }, - { - "tableName": "NetworkValidationInfoEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nviUrlHash` INTEGER NOT NULL, `nviKey` INTEGER NOT NULL, `nviVaryHeader` TEXT, `nviLastModified` INTEGER NOT NULL, `nviEtag` TEXT, `nviConsistentThrough` INTEGER, `nviLastChecked` INTEGER NOT NULL, PRIMARY KEY(`nviUrlHash`, `nviKey`))", - "fields": [ - { - "fieldPath": "nviUrlHash", - "columnName": "nviUrlHash", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviKey", - "columnName": "nviKey", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviVaryHeader", - "columnName": "nviVaryHeader", - "affinity": "TEXT" - }, - { - "fieldPath": "nviLastModified", - "columnName": "nviLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviEtag", - "columnName": "nviEtag", - "affinity": "TEXT" - }, - { - "fieldPath": "nviConsistentThrough", - "columnName": "nviConsistentThrough", - "affinity": "INTEGER" - }, - { - "fieldPath": "nviLastChecked", - "columnName": "nviLastChecked", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "nviUrlHash", - "nviKey" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1420dddb863a361cd3cf0b7fdd8d2732')" - ] - } -} \ No newline at end of file diff --git a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/4.json b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/4.json deleted file mode 100644 index 08624a533..000000000 --- a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectAppDatabase/4.json +++ /dev/null @@ -1,224 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 4, - "identityHash": "5743446f4e471787e8b043ab3497bb84", - "entities": [ - { - "tableName": "SchoolDirectoryEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rdUid` INTEGER NOT NULL, `rdUrl` TEXT NOT NULL, `rdInvitePrefix` TEXT NOT NULL, PRIMARY KEY(`rdUid`))", - "fields": [ - { - "fieldPath": "rdUid", - "columnName": "rdUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rdUrl", - "columnName": "rdUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rdInvitePrefix", - "columnName": "rdInvitePrefix", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "rdUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntryEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`reUid` INTEGER NOT NULL, `reSelf` TEXT NOT NULL, `reXapi` TEXT NOT NULL, `reOneRoster` TEXT NOT NULL, `reRespectExt` TEXT, `reRpId` TEXT, `reLastModified` INTEGER NOT NULL, `reStored` INTEGER NOT NULL, PRIMARY KEY(`reUid`))", - "fields": [ - { - "fieldPath": "reUid", - "columnName": "reUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "reSelf", - "columnName": "reSelf", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reXapi", - "columnName": "reXapi", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reOneRoster", - "columnName": "reOneRoster", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reRespectExt", - "columnName": "reRespectExt", - "affinity": "TEXT" - }, - { - "fieldPath": "reRpId", - "columnName": "reRpId", - "affinity": "TEXT" - }, - { - "fieldPath": "reLastModified", - "columnName": "reLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "reStored", - "columnName": "reStored", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "reUid" - ] - } - }, - { - "tableName": "SchoolDirectoryEntryLangMapEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sdelUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sdelReUid` INTEGER NOT NULL, `sdelLang` TEXT NOT NULL, `sdelRegion` TEXT, `sdelValue` TEXT NOT NULL, `sdelPropId` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "sdelUid", - "columnName": "sdelUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sdelReUid", - "columnName": "sdelReUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sdelLang", - "columnName": "sdelLang", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sdelRegion", - "columnName": "sdelRegion", - "affinity": "TEXT" - }, - { - "fieldPath": "sdelValue", - "columnName": "sdelValue", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sdelPropId", - "columnName": "sdelPropId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "sdelUid" - ] - } - }, - { - "tableName": "SchoolConfigEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rcUid` INTEGER NOT NULL, `dbUrl` TEXT NOT NULL, PRIMARY KEY(`rcUid`))", - "fields": [ - { - "fieldPath": "rcUid", - "columnName": "rcUid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dbUrl", - "columnName": "dbUrl", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "rcUid" - ] - } - }, - { - "tableName": "NetworkValidationInfoEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nviUrlHash` INTEGER NOT NULL, `nviKey` INTEGER NOT NULL, `nviVaryHeader` TEXT, `nviLastModified` INTEGER NOT NULL, `nviEtag` TEXT, `nviConsistentThrough` INTEGER, `nviLastChecked` INTEGER NOT NULL, PRIMARY KEY(`nviUrlHash`, `nviKey`))", - "fields": [ - { - "fieldPath": "nviUrlHash", - "columnName": "nviUrlHash", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviKey", - "columnName": "nviKey", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviVaryHeader", - "columnName": "nviVaryHeader", - "affinity": "TEXT" - }, - { - "fieldPath": "nviLastModified", - "columnName": "nviLastModified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nviEtag", - "columnName": "nviEtag", - "affinity": "TEXT" - }, - { - "fieldPath": "nviConsistentThrough", - "columnName": "nviConsistentThrough", - "affinity": "INTEGER" - }, - { - "fieldPath": "nviLastChecked", - "columnName": "nviLastChecked", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "nviUrlHash", - "nviKey" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5743446f4e471787e8b043ab3497bb84')" - ] - } -} \ No newline at end of file diff --git a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/12.json b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/12.json index 465d375c7..1f31e94f2 100644 --- a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/12.json +++ b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/12.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 12, - "identityHash": "3cd745bbc0ddf2c692bd454cbb19fc7f", + "identityHash": "f0a2050e7777d6184c88d39f5d6f8d8b", "entities": [ { "tableName": "SchoolAppEntity", @@ -1213,6 +1213,61 @@ ] } }, + { + "tableName": "BookmarkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bPersonUid` TEXT NOT NULL, `bPersonUidHash` INTEGER NOT NULL, `bLearningUnitManifestUrl` TEXT NOT NULL, `bLearningUnitUrlHash` INTEGER NOT NULL, `bStatus` INTEGER NOT NULL, `bLastModified` INTEGER NOT NULL, `bStored` INTEGER NOT NULL, PRIMARY KEY(`bPersonUidHash`, `bLearningUnitUrlHash`))", + "fields": [ + { + "fieldPath": "bPersonUid", + "columnName": "bPersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bPersonUidHash", + "columnName": "bPersonUidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bLearningUnitManifestUrl", + "columnName": "bLearningUnitManifestUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bLearningUnitUrlHash", + "columnName": "bLearningUnitUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bStatus", + "columnName": "bStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bLastModified", + "columnName": "bLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bStored", + "columnName": "bStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bPersonUidHash", + "bLearningUnitUrlHash" + ] + } + }, { "tableName": "LangMapEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lmeId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lmeTopParentType` INTEGER NOT NULL, `lmeTopParentUid1` INTEGER NOT NULL, `lmeTopParentUid2` INTEGER NOT NULL, `lmePropType` INTEGER NOT NULL, `lmePropFk` INTEGER NOT NULL, `lmeLang` TEXT NOT NULL, `lmeRegion` TEXT, `lmeValue` TEXT NOT NULL)", @@ -1737,7 +1792,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3cd745bbc0ddf2c692bd454cbb19fc7f')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f0a2050e7777d6184c88d39f5d6f8d8b')" ] } } \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt index 5f41ee84a..6a0c2086e 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt @@ -36,6 +36,7 @@ import world.respect.datalayer.db.school.daos.ReportEntityDao import world.respect.datalayer.db.realm.entities.IndicatorEntity import world.respect.datalayer.db.school.daos.AssignmentEntityDao import world.respect.datalayer.db.school.daos.AssignmentLearningResourceRefEntityDao +import world.respect.datalayer.db.school.daos.BookmarkDao import world.respect.datalayer.db.school.daos.ClassEntityDao import world.respect.datalayer.db.school.daos.ClassPermissionEntityDao import world.respect.datalayer.db.school.daos.EnrollmentEntityDao @@ -57,6 +58,7 @@ import world.respect.datalayer.db.school.entities.ReportEntity import world.respect.datalayer.db.school.entities.SchoolAppEntity import world.respect.datalayer.db.school.entities.WriteQueueItemEntity import world.respect.datalayer.db.school.daos.SchoolPermissionGrantDao +import world.respect.datalayer.db.school.entities.BookmarkEntity import world.respect.datalayer.db.school.entities.ClassPermissionEntity import world.respect.datalayer.db.school.entities.PullSyncStatusEntity import world.respect.datalayer.db.school.entities.SchoolPermissionGrantEntity @@ -93,6 +95,7 @@ import world.respect.datalayer.school.model.Report PullSyncStatusEntity::class, PersonQrBadgeEntity::class, InviteEntity::class, + BookmarkEntity::class, //Shared (used by OPDS) LangMapEntity::class, @@ -150,6 +153,8 @@ abstract class RespectSchoolDatabase: RoomDatabase() { abstract fun getPullSyncStatusEntityDao(): PullSyncStatusEntityDao + abstract fun getBookmarkDao(): BookmarkDao + abstract fun getLangMapEntityDao(): LangMapEntityDao abstract fun getOpdsFeedEntityDao(): OpdsFeedEntityDao @@ -179,6 +184,7 @@ abstract class RespectSchoolDatabase: RoomDatabase() { OpdsFacetEntity.TABLE_ID, OpdsGroupEntity.TABLE_ID, OpdsFeedEntity.TABLE_ID, + BookmarkEntity.TABLE_ID ) } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt index 2ad81f42a..10931292a 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt @@ -7,6 +7,7 @@ import world.respect.datalayer.UidNumberMapper import world.respect.datalayer.db.school.opds.OpdsPublicationDataSourceDb import world.respect.datalayer.db.school.opds.OpdsFeedDataSourceDb import world.respect.datalayer.db.school.AssignmentDatasourceDb +import world.respect.datalayer.db.school.BookmarkDataSourceDb import world.respect.datalayer.db.school.ClassDatasourceDb import world.respect.datalayer.db.school.EnrollmentDataSourceDb import world.respect.datalayer.db.school.GetAuthenticatedPersonUseCase @@ -20,6 +21,7 @@ import world.respect.datalayer.db.school.ReportDataSourceDb import world.respect.datalayer.db.school.SchoolAppDataSourceDb import world.respect.datalayer.db.school.SchoolPermissionGrantDataSourceDb import world.respect.datalayer.school.AssignmentDataSourceLocal +import world.respect.datalayer.school.BookmarkDataSourceLocal import world.respect.datalayer.school.ClassDataSourceLocal import world.respect.datalayer.school.DummySchoolConfigSettingsDataSource import world.respect.datalayer.school.EnrollmentDataSourceLocal @@ -117,6 +119,10 @@ class SchoolDataSourceDb( AssignmentDatasourceDb(schoolDb, uidNumberMapper, authenticatedUser) } + override val bookmarkDataSource: BookmarkDataSourceLocal by lazy { + BookmarkDataSourceDb(schoolDb, uidNumberMapper, authenticatedUser, json) + } + override val opdsPublicationDataSource: OpdsPublicationDataSourceLocal by lazy { OpdsPublicationDataSourceDb( respectSchoolDatabase = schoolDb, diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/BookmarkDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/BookmarkDataSourceDb.kt new file mode 100644 index 000000000..eb431336c --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/BookmarkDataSourceDb.kt @@ -0,0 +1,125 @@ +package world.respect.datalayer.db.school + +import androidx.room.Transactor +import androidx.room.useWriterConnection +import io.ktor.http.Url +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import world.respect.datalayer.AuthenticatedUserPrincipalId +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.DataReadyState +import world.respect.datalayer.UidNumberMapper +import world.respect.datalayer.db.RespectSchoolDatabase +import world.respect.datalayer.db.school.adapters.toModel +import world.respect.datalayer.db.school.adapters.toEntities +import world.respect.datalayer.school.BookmarkDataSource +import world.respect.datalayer.school.BookmarkDataSourceLocal +import world.respect.datalayer.school.model.Bookmark +import world.respect.datalayer.shared.paging.IPagingSourceFactory +import kotlin.time.Clock + +class BookmarkDataSourceDb( + private val schoolDb: RespectSchoolDatabase, + private val uidNumberMapper: UidNumberMapper, + private val authenticatedUser: AuthenticatedUserPrincipalId, + private val json: Json, +) : BookmarkDataSourceLocal { + + override fun getBookmarkStatus(personUid: String, url: Url): Flow { + return schoolDb.getBookmarkDao().getBookmarkStatus( + personUid = personUid, + url = url.toString() + ) + } + + override suspend fun store(list: List) { + + if (list.isEmpty()) + return + + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + val now = Clock.System.now() + schoolDb.getBookmarkDao().upsert( + list.map { + it.copy( + stored = now + ).toEntities(uidNumberMapper).bookmark + } + ) + } + } + } + + override suspend fun list( + loadParams: DataLoadParams, + listParams: BookmarkDataSource.GetListParams + ): DataLoadState> { + return DataReadyState( + data = schoolDb.getBookmarkDao() + .list( + personUid = requireNotNull(listParams.personUid), + includeDeleted = listParams.includeDeleted + ) + .map { + it.toModel(json) + } + ) + } + + override suspend fun findBookmarksWithMissingPublication(personUid: String): List { + return schoolDb.getBookmarkDao() + .findBookmarksWithMissingPublication(personUid) + .map { entity -> + entity.toModel(json) + } + } + + override fun listAsFlow( + loadParams: DataLoadParams, + listParams: BookmarkDataSource.GetListParams + ): Flow>> { + return schoolDb.getBookmarkDao().listAsFlow( + personUid = requireNotNull(listParams.personUid), + includeDeleted = listParams.includeDeleted + ).map { entityList -> + DataReadyState( + data = entityList.map { it.toModel(json) } + ) + } + } + + override suspend fun updateLocal( + list: List, + forceOverwrite: Boolean + ) { + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + val now = Clock.System.now() + schoolDb.getBookmarkDao().upsert( + bookmarks = list.filter { bookmark -> + + forceOverwrite || schoolDb.getBookmarkDao().getBookmarkLastModified( + personUid = bookmark.personUid, + urlHash = uidNumberMapper( + bookmark.learningUnitManifestUrl.toString() + ) + ).let { it ?: 0L } < bookmark.lastModified.toEpochMilliseconds() + }.map { + it.copy(stored = now).toEntities(uidNumberMapper).bookmark + } + ) + } + } + } + + override suspend fun findByUidList(uids: List): List { + return schoolDb.getBookmarkDao() + .findByUidList( + uids.map { uidNumberMapper(it) } + ) + .map { it.toModel(json) } + } +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/BookmarkAdapter.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/BookmarkAdapter.kt new file mode 100644 index 000000000..86457adb3 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/BookmarkAdapter.kt @@ -0,0 +1,144 @@ +package world.respect.datalayer.db.school.adapters + +import androidx.room.Embedded +import androidx.room.Relation +import kotlinx.serialization.json.Json +import world.respect.datalayer.UidNumberMapper +import world.respect.datalayer.db.school.entities.BookmarkEntity +import world.respect.datalayer.db.school.opds.adapters.asModels +import world.respect.datalayer.db.school.opds.entities.OpdsPublicationEntity +import world.respect.datalayer.db.school.opds.entities.ReadiumLinkEntity +import world.respect.datalayer.db.shared.adapters.toModel +import world.respect.datalayer.db.shared.entities.LangMapEntity +import world.respect.datalayer.school.model.Bookmark +import world.respect.lib.opds.model.LangMap +import world.respect.lib.opds.model.ReadiumLink +import world.respect.libutil.ext.resolve + +/** + * Publication + its relations + */ +data class OpdsPublicationEntities( + + @Embedded + val publication: OpdsPublicationEntity, + + @Relation( + parentColumn = "opeUid", + entityColumn = "lmeTopParentUid1" + ) + val langMaps: List = emptyList(), + + @Relation( + parentColumn = "opeUid", + entityColumn = "rleOpdsParentUid" + ) + val links: List = emptyList() +) + + +/** + * Bookmark + linked publication + */ +data class BookmarkEntities( + + @Embedded + val bookmark: BookmarkEntity, + + @Relation( + parentColumn = "bUrlHash", + entityColumn = "opeUrlHash", + entity = OpdsPublicationEntity::class + + ) + val opdsPublicationEntities: OpdsPublicationEntities? = null +) + +fun BookmarkEntities.toModel( + json: Json +): Bookmark { + + fun List.extract( + propType: LangMapEntity.PropType, + publicationUid: Long + ): LangMap? { + return this + .filter { + it.lmePropType == propType && + it.lmeTopParentUid1 == publicationUid + } + .takeIf { it.isNotEmpty() } + ?.toModel() + } + + fun List.asModelsSub(propType: ReadiumLinkEntity.PropertyType): List { + val pubUid = opdsPublicationEntities?.publication?.opeUid ?: return emptyList() + return asModels(json = json, propType = propType, propFk = pubUid) + } + + val title = opdsPublicationEntities?.langMaps?.extract( + LangMapEntity.PropType.OPDS_PUB_TITLE, + opdsPublicationEntities.publication.opeUid + ) + + val subTitle = opdsPublicationEntities?.langMaps?.extract( + LangMapEntity.PropType.OPDS_PUB_SUBTITLE, + opdsPublicationEntities.publication.opeUid + ) + + val images = + opdsPublicationEntities?.links?.asModelsSub(ReadiumLinkEntity.PropertyType.OPDS_PUB_IMAGES) + val imageUrl = images?.firstOrNull()?.href?.let { + bookmark.bUrl.resolve(it) + } + + val type = opdsPublicationEntities + ?.publication + ?.opeMdType + ?.toString() + ?.substringAfterLast("/") + + val language = opdsPublicationEntities + ?.publication + ?.opeMdLanguage + ?.firstOrNull() + + val languageName = language?.let { + java.util.Locale(it).displayLanguage + } + + return Bookmark( + status = bookmark.bStatus, + lastModified = bookmark.bLastModified, + stored = bookmark.bStored, + personUid = bookmark.bPersonUid, + learningUnitManifestUrl = bookmark.bUrl, + appManifestUrl = bookmark.bAppManifestUrl, + title = title, + subTitle = subTitle, + imageUrl = imageUrl, + type = type, + language = languageName + ) +} + +fun Bookmark.toEntities( + uidNumberMapper: UidNumberMapper +): BookmarkEntities { + + val bookmarkEntity = BookmarkEntity( + bStatus = status, + bLastModified = lastModified, + bStored = stored, + bPersonUid = personUid, + bPersonUidHash = uidNumberMapper(personUid), + bUrl = learningUnitManifestUrl, + bUrlHash = uidNumberMapper(learningUnitManifestUrl.toString()), + bAppManifestUrl = appManifestUrl + ) + + return BookmarkEntities( + bookmark = bookmarkEntity, + opdsPublicationEntities = null + ) +} diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/BookmarkDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/BookmarkDao.kt new file mode 100644 index 000000000..40de64bee --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/BookmarkDao.kt @@ -0,0 +1,111 @@ +package world.respect.datalayer.db.school.daos + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.db.school.adapters.BookmarkEntities +import world.respect.datalayer.db.school.entities.BookmarkEntity +import world.respect.datalayer.school.model.StatusEnum + +@Dao +interface BookmarkDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(bookmarks: List) + + @Query( + """ + SELECT EXISTS( + SELECT 1 FROM BookmarkEntity + WHERE bPersonUid = :personUid + AND bUrl = :url + AND bStatus = :status + ) + """ + ) + fun getBookmarkStatus( + personUid: String, + url: String, + status: StatusEnum = StatusEnum.ACTIVE + ): Flow + + @Transaction + @Query(""" + SELECT * + FROM BookmarkEntity + WHERE bPersonUid = :personUid + AND (:includeDeleted OR bStatus = :activeStatus) + ORDER BY bLastModified ASC + """) + suspend fun list( + personUid: String, + includeDeleted: Boolean = false, + activeStatus: StatusEnum = StatusEnum.ACTIVE + ): List + + @Transaction + @Query(""" + SELECT * + FROM BookmarkEntity + WHERE bPersonUid = :personUid + AND (:includeDeleted OR bStatus = :activeStatus) + ORDER BY bLastModified ASC + """) + fun listAsFlow( + personUid: String, + includeDeleted: Boolean = false, + activeStatus: StatusEnum = StatusEnum.ACTIVE + ): Flow> + + @Transaction + @Query(""" + SELECT * + FROM BookmarkEntity + WHERE bPersonUid = :personUid + AND (:includeDeleted OR bStatus = :activeStatus) + ORDER BY bLastModified ASC + """) + fun listAsPagingSource( + personUid: String, + includeDeleted: Boolean = false, + activeStatus: StatusEnum = StatusEnum.ACTIVE + ): PagingSource + + @Query(""" + SELECT * + FROM BookmarkEntity + WHERE bPersonUid = :personUid + AND NOT EXISTS ( + SELECT 1 + FROM OpdsPublicationEntity + WHERE opeUrlHash = bUrlHash + ) + """) + suspend fun findBookmarksWithMissingPublication( + personUid: String + ): List + + @Query(""" + SELECT bLastModified + FROM BookmarkEntity + WHERE bPersonUid = :personUid + AND bUrlHash = :urlHash + """) + suspend fun getBookmarkLastModified( + personUid: String, + urlHash: Long + ): Long? + + @Query(""" + SELECT * + FROM BookmarkEntity + WHERE bPersonUid IN (:uids) + """) + suspend fun findByUidList( + uids: List + ): List +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/BookmarkEntity.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/BookmarkEntity.kt new file mode 100644 index 000000000..2c0b14d0e --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/BookmarkEntity.kt @@ -0,0 +1,28 @@ +package world.respect.datalayer.db.school.entities + +import androidx.room.Entity +import io.ktor.http.Url +import world.respect.datalayer.school.model.StatusEnum +import kotlin.time.Instant + +@Entity( + primaryKeys = ["bPersonUidHash", "bUrl"] +) + +data class BookmarkEntity( + val bPersonUid: String, + val bPersonUidHash: Long, + val bUrl: Url, + val bUrlHash: Long, + val bStatus: StatusEnum = StatusEnum.ACTIVE, + val bLastModified: Instant, + val bStored: Instant, + val bAppManifestUrl: Url +) { + + companion object { + const val TABLE_ID = 11 + } + +} + diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/adapters/OpdsPublicationEntityAdapter.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/adapters/OpdsPublicationEntityAdapter.kt index 31634d94c..768d6f49c 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/adapters/OpdsPublicationEntityAdapter.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/adapters/OpdsPublicationEntityAdapter.kt @@ -79,6 +79,7 @@ fun OpdsPublication.asEntities( opeMdDuration = metadata.duration, ), langMapEntities = metadata.title.toEntitiesSub(LangMapEntity.PropType.OPDS_PUB_TITLE) + + metadata.sortAs.toEntitiesSub(LangMapEntity.PropType.OPDS_PUB_SORT_AS) + metadata.subtitle.toEntitiesSub(LangMapEntity.PropType.OPDS_PUB_SUBTITLE), linkEntities = links.toEntitiesSub(ReadiumLinkEntity.PropertyType.OPDS_PUB_LINKS) + diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/entities/OpdsPublicationEntity.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/entities/OpdsPublicationEntity.kt index fa460202c..6153852b6 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/entities/OpdsPublicationEntity.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/entities/OpdsPublicationEntity.kt @@ -10,6 +10,8 @@ import io.ktor.http.Url * @property opeOfeUid where this OpdsPublicationEntity is part of a feed, the UID of the feed, * otherwise 0 * @property opeOgeUid where this OpdsPublicationEntity is part of a group, the UID of the group, + * + * * otherwise 0 * @property opeIndex where this OpdsPublicationEntity is part of a feed or group, the index of this * publication within the feed or group respectively. diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt index 80b28b01b..e1417b5f4 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt @@ -8,6 +8,7 @@ import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.http.school.opds.OpdsPublicationDataSourceHttp import world.respect.datalayer.http.school.opds.OpdsFeedDataSourceHttp import world.respect.datalayer.http.school.AssignmentDataSourceHttp +import world.respect.datalayer.http.school.BookmarkDataSourceHttp import world.respect.datalayer.http.school.ClassDataSourceHttp import world.respect.datalayer.http.school.EnrollmentDataSourceHttp import world.respect.datalayer.http.school.InviteDataSourceHttp @@ -21,6 +22,7 @@ import world.respect.datalayer.networkvalidation.BaseDataSourceValidationHelper import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper import world.respect.datalayer.school.opds.OpdsPublicationDataSource import world.respect.datalayer.school.AssignmentDataSource +import world.respect.datalayer.school.BookmarkDataSource import world.respect.datalayer.school.ClassDataSource import world.respect.datalayer.school.DummySchoolConfigSettingsDataSource import world.respect.datalayer.school.EnrollmentDataSource @@ -154,6 +156,15 @@ class SchoolDataSourceHttp( validationHelper = validationHelper, ) } + override val bookmarkDataSource: BookmarkDataSource by lazy { + BookmarkDataSourceHttp( + schoolUrl = schoolUrl, + schoolDirectoryEntryDataSource = schoolDirectoryEntryDataSource, + httpClient = httpClient, + tokenProvider = tokenProvider, + validationHelper = validationHelper, + ) + } override val opdsPublicationDataSource: OpdsPublicationDataSource by lazy { OpdsPublicationDataSourceHttp( diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/BookmarkDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/BookmarkDataSourceHttp.kt new file mode 100644 index 000000000..6adfa367a --- /dev/null +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/BookmarkDataSourceHttp.kt @@ -0,0 +1,97 @@ +package world.respect.datalayer.http.school + +import io.ktor.client.HttpClient +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.contentType +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.AuthTokenProvider +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.ext.getAsDataLoadState +import world.respect.datalayer.ext.getDataLoadResultAsFlow +import world.respect.datalayer.ext.useTokenProvider +import world.respect.datalayer.ext.useValidationCacheControl +import world.respect.datalayer.http.ext.appendCommonListParams +import world.respect.datalayer.http.ext.appendIfNotNull +import world.respect.datalayer.http.ext.respectEndpointUrl +import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper +import world.respect.datalayer.school.BookmarkDataSource +import world.respect.datalayer.school.BookmarkDataSource.Companion.PERSON_UID +import world.respect.datalayer.school.model.Bookmark +import world.respect.datalayer.schooldirectory.SchoolDirectoryEntryDataSource + + +class BookmarkDataSourceHttp( + override val schoolUrl: Url, + override val schoolDirectoryEntryDataSource: SchoolDirectoryEntryDataSource, + private val httpClient: HttpClient, + private val tokenProvider: AuthTokenProvider, + private val validationHelper: ExtendedDataSourceValidationHelper?, +) : BookmarkDataSource, SchoolUrlBasedDataSource { + + private suspend fun BookmarkDataSource.GetListParams.urlWithParams(): Url { + return URLBuilder(respectEndpointUrl(BookmarkDataSource.ENDPOINT_NAME)) + .apply { + parameters.appendCommonListParams(common) + parameters.appendIfNotNull(PERSON_UID, personUid) + } + .build() + } + + override fun getBookmarkStatus( + personUid: String, + url: Url + ): Flow { + throw IllegalArgumentException( + "Bookmark status is not supported in HTTP datasource." + ) + } + + + override suspend fun store(list: List) { + httpClient.post( + respectEndpointUrl(BookmarkDataSource.ENDPOINT_NAME) + ) { + useTokenProvider(tokenProvider) + contentType(ContentType.Application.Json) + setBody(list) + } + } + + override suspend fun list( + loadParams: DataLoadParams, + listParams: BookmarkDataSource.GetListParams + ): DataLoadState> { + return httpClient.getAsDataLoadState>( + url = listParams.urlWithParams(), + validationHelper = validationHelper + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + } + } + + override suspend fun findBookmarksWithMissingPublication(personUid: String): List { + throw IllegalArgumentException( + "Find Bookmarks is not supported in HTTP datasource." + ) + } + + override fun listAsFlow( + loadParams: DataLoadParams, + listParams: BookmarkDataSource.GetListParams + ): Flow>> { + return httpClient.getDataLoadResultAsFlow>( + urlFn = { listParams.urlWithParams() }, + dataLoadParams = loadParams, + validationHelper = validationHelper, + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + } + } +} diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt index 280de6156..252c0562d 100644 --- a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt @@ -6,6 +6,7 @@ import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHel import world.respect.datalayer.repository.opds.OpdsPublicationDataSourceRepository import world.respect.datalayer.repository.opds.OpdsFeedDataSourceRepository import world.respect.datalayer.repository.school.AssignmentDataSourceRepository +import world.respect.datalayer.repository.school.BookmarkDataSourceRepository import world.respect.datalayer.repository.school.ClassDataSourceRepository import world.respect.datalayer.repository.school.EnrollmentDataSourceRepository import world.respect.datalayer.repository.school.PersonDataSourceRepository @@ -124,6 +125,15 @@ class SchoolDataSourceRepository( ) } + override val bookmarkDataSource: BookmarkDataSourceRepository by lazy { + BookmarkDataSourceRepository( + local = local.bookmarkDataSource, + remote = remote.bookmarkDataSource, + validationHelper = validationHelper, + remoteWriteQueue = remoteWriteQueue, + ) + } + override val opdsPublicationDataSource: OpdsPublicationDataSource by lazy { OpdsPublicationDataSourceRepository( local = local.opdsPublicationDataSource, diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/BookmarkDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/BookmarkDataSourceRepository.kt new file mode 100644 index 000000000..183cd4c50 --- /dev/null +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/BookmarkDataSourceRepository.kt @@ -0,0 +1,95 @@ +package world.respect.datalayer.repository.school + +import io.github.aakira.napier.Napier +import io.ktor.http.Url +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.ext.combineWithRemote +import world.respect.datalayer.ext.combineWithRemoteIfNotNull +import world.respect.datalayer.ext.updateFromRemoteListIfNeeded +import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper +import world.respect.datalayer.school.BookmarkDataSource +import world.respect.datalayer.school.BookmarkDataSourceLocal +import world.respect.datalayer.school.model.Bookmark +import world.respect.datalayer.school.writequeue.RemoteWriteQueue +import world.respect.datalayer.school.writequeue.WriteQueueItem +import world.respect.datalayer.shared.DataLayerTags +import world.respect.datalayer.shared.RepositoryModelDataSource +import world.respect.libutil.util.time.systemTimeInMillis + + +class BookmarkDataSourceRepository( + override val local: BookmarkDataSourceLocal, + override val remote: BookmarkDataSource, + private val validationHelper: ExtendedDataSourceValidationHelper, + private val remoteWriteQueue: RemoteWriteQueue, +) : BookmarkDataSource, RepositoryModelDataSource { + override fun getBookmarkStatus( + personUid: String, + url: Url + ): Flow { + return local.getBookmarkStatus(personUid, url) + } + + override suspend fun store(list: List) { + local.store(list) + try { + remote.store(list) + } catch (e: Throwable) { + Napier.w( + message = "BookmarkDataSourceRepository: remote store failed, queuing for later", + throwable = e, + tag = DataLayerTags.TAG_DATALAYER + ) + val timeNow = systemTimeInMillis() + remoteWriteQueue.add( + list.map { + WriteQueueItem( + model = WriteQueueItem.Model.BOOKMARK, + uid = it.personUid, + timeQueued = timeNow, + ) + } + ) + } + } + + override suspend fun list( + loadParams: DataLoadParams, + listParams: BookmarkDataSource.GetListParams + ): DataLoadState> { + val remote = try { + remote.list(loadParams, listParams.copy(includeDeleted = true)).also { + local.updateFromRemoteListIfNeeded(it, validationHelper) + } + } catch (e: Throwable) { + Napier.w( + message = "BookmarkDataSourceRepository.list() failed:", + throwable = e, + tag = DataLayerTags.TAG_DATALAYER + ) + null + } + + return local.list(loadParams, listParams).combineWithRemoteIfNotNull(remote) + } + + override suspend fun findBookmarksWithMissingPublication(personUid: String): List { + return local.findBookmarksWithMissingPublication(personUid) + } + + override fun listAsFlow( + loadParams: DataLoadParams, + listParams: BookmarkDataSource.GetListParams + ): Flow>> { + return local.listAsFlow(loadParams, listParams).combineWithRemote( + remoteFlow = remote.listAsFlow( + loadParams, listParams.copy(includeDeleted = true) + ).onEach { + local.updateFromRemoteListIfNeeded(it, validationHelper) + } + ) + } +} diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt index 283e02a20..83d03c978 100644 --- a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt @@ -83,10 +83,13 @@ class DrainRemoteWriteQueueUseCase( remoteWriteQueue.markSent(ids = listOf(item.queueItemId)) } + + WriteQueueItem.Model.BOOKMARK -> { + repository.bookmarkDataSource.sendToRemote(listOf(item)) + } } } } while(true) } - } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSource.kt index d662ab637..d78fce6d4 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSource.kt @@ -1,5 +1,6 @@ package world.respect.datalayer +import world.respect.datalayer.school.BookmarkDataSource import world.respect.datalayer.school.opds.OpdsPublicationDataSource import world.respect.datalayer.school.AssignmentDataSource import world.respect.datalayer.school.ClassDataSource @@ -48,6 +49,8 @@ interface SchoolDataSource { val inviteDataSource: InviteDataSource + val bookmarkDataSource: BookmarkDataSource + val opdsPublicationDataSource: OpdsPublicationDataSource val opdsFeedDataSource: OpdsFeedDataSource diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt index ea5276334..aed163834 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt @@ -2,6 +2,7 @@ package world.respect.datalayer import world.respect.datalayer.school.opds.OpdsPublicationDataSourceLocal import world.respect.datalayer.school.AssignmentDataSourceLocal +import world.respect.datalayer.school.BookmarkDataSourceLocal import world.respect.datalayer.school.ClassDataSourceLocal import world.respect.datalayer.school.EnrollmentDataSourceLocal import world.respect.datalayer.school.InviteDataSourceLocal @@ -43,6 +44,8 @@ interface SchoolDataSourceLocal: SchoolDataSource { override val inviteDataSource: InviteDataSourceLocal + override val bookmarkDataSource: BookmarkDataSourceLocal + override val opdsPublicationDataSource: OpdsPublicationDataSourceLocal override val opdsFeedDataSource: OpdsFeedDataSourceLocal diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/BookmarkDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/BookmarkDataSource.kt new file mode 100644 index 000000000..0a53cf2da --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/BookmarkDataSource.kt @@ -0,0 +1,61 @@ +package world.respect.datalayer.school + +import io.ktor.http.Url +import io.ktor.util.StringValues +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.school.model.Bookmark +import world.respect.datalayer.shared.WritableDataSource +import world.respect.datalayer.shared.paging.IPagingSourceFactory +import world.respect.datalayer.shared.params.GetListCommonParams + +interface BookmarkDataSource : WritableDataSource { + + data class GetListParams( + val common: GetListCommonParams = GetListCommonParams(), + val personUid: String? = null, + val includeDeleted: Boolean = false, + ) { + companion object { + fun fromParams(params: StringValues): GetListParams { + return GetListParams( + common = GetListCommonParams.fromParams(params), + personUid = params[PERSON_UID], + includeDeleted = params[INCLUDE_DELETED]?.toBoolean() ?: false, + ) + } + } + } + + fun getBookmarkStatus( + personUid: String, + url: Url + ): Flow + + override suspend fun store( + list: List + ) + + suspend fun list( + loadParams: DataLoadParams, + listParams: GetListParams, + ): DataLoadState> + + suspend fun findBookmarksWithMissingPublication( + personUid: String + ): List + + fun listAsFlow( + loadParams: DataLoadParams = DataLoadParams(), + listParams: GetListParams = GetListParams(), + ): Flow>> + + companion object { + + const val ENDPOINT_NAME = "bookmark" + const val PERSON_UID = "personUid" + const val INCLUDE_DELETED = "includeDeleted" + + } +} diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/BookmarkDataSourceLocal.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/BookmarkDataSourceLocal.kt new file mode 100644 index 000000000..0a77bdde5 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/BookmarkDataSourceLocal.kt @@ -0,0 +1,10 @@ +package world.respect.datalayer.school + +import world.respect.datalayer.school.model.Bookmark +import world.respect.datalayer.shared.LocalModelDataSource + + +interface BookmarkDataSourceLocal: BookmarkDataSource, LocalModelDataSource + + + diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Bookmark.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Bookmark.kt new file mode 100644 index 000000000..3dc70b610 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Bookmark.kt @@ -0,0 +1,32 @@ +package world.respect.datalayer.school.model + +import io.ktor.http.Url +import kotlinx.serialization.Serializable +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.shared.ModelWithTimes +import world.respect.lib.opds.model.LangMap +import world.respect.lib.opds.model.OpdsPublication +import world.respect.lib.serializers.InstantAsISO8601 +import kotlin.time.Clock + +@Serializable +data class Bookmark( + val personUid: String, + val learningUnitManifestUrl: Url, + val status: StatusEnum = StatusEnum.ACTIVE, + override val lastModified: InstantAsISO8601 = Clock.System.now(), + override val stored: InstantAsISO8601 = Clock.System.now(), + val appManifestUrl: Url, + val title: LangMap? = null, + val subTitle: LangMap? = null, + val imageUrl: Url? = null, + val type: String? = null, + val language: String? = null, + val grade: String? = null +) : ModelWithTimes + + +data class BookmarkDetails( + val bookmark: Bookmark, + val app: DataLoadState? = null +) diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt index aae2c9c6e..4b7fd140b 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt @@ -26,7 +26,7 @@ class WriteQueueItem( PERSON_QRBADGE(8), INVITE(9), OPDS_FEED(10), - + BOOKMARK(11) ; diff --git a/respect-lib-shared/src/commonMain/composeResources/drawable/no_bookmark.xml b/respect-lib-shared/src/commonMain/composeResources/drawable/no_bookmark.xml new file mode 100644 index 000000000..ecf659f82 --- /dev/null +++ b/respect-lib-shared/src/commonMain/composeResources/drawable/no_bookmark.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml index 52b9cb015..3baa58f0b 100644 --- a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml +++ b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml @@ -18,6 +18,7 @@ Add App Lessons Apps + Home App Assignments Assignment @@ -43,6 +44,12 @@ Downloaded Share Assign + Bookmark + No bookmarks yet + Bookmark a lesson or a playlist to see it here. + Bookmarks + 1 Remove bookmark + Undo Related Lessons Duration More Info diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/ext/UiTextExt.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/ext/UiTextExt.kt new file mode 100644 index 000000000..a2ef64381 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/ext/UiTextExt.kt @@ -0,0 +1,13 @@ +package world.respect.shared.ext + +import org.jetbrains.compose.resources.getString +import world.respect.shared.resources.StringResourceUiText +import world.respect.shared.resources.StringUiText +import world.respect.shared.resources.UiText + +suspend fun UiText.asString(): String { + return when(this) { + is StringResourceUiText -> getString(resource) + is StringUiText -> text + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt index 2fd39609b..e3a8a9648 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt @@ -151,6 +151,9 @@ data class AssignmentEdit( } +@Serializable +object BookmarkList : RespectAppRoute + @Serializable object ClazzList : RespectAppRoute diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/app/appstate/Snack.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/app/appstate/Snack.kt index c985995ca..fca02065a 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/app/appstate/Snack.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/app/appstate/Snack.kt @@ -4,6 +4,6 @@ import world.respect.shared.resources.UiText data class Snack( val message: UiText, - val action: String? = null, + val action: UiText? = null, val onAction: (() -> Unit)? = null, ) diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/launcher/AppLauncherViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/launcher/AppLauncherViewModel.kt index caf58a19a..20dbcaf00 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/launcher/AppLauncherViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/launcher/AppLauncherViewModel.kt @@ -27,7 +27,7 @@ import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.devmode.GetDevModeEnabledUseCase import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.app -import world.respect.shared.generated.resources.apps +import world.respect.shared.generated.resources.home import world.respect.shared.generated.resources.empty_list_description_admin import world.respect.shared.generated.resources.empty_list_description_non_admin import world.respect.shared.navigation.AppsDetail @@ -92,7 +92,7 @@ class AppLauncherViewModel( init { _appUiState.update { it.copy( - title = Res.string.apps.asUiText(), + title = Res.string.home.asUiText(), onClickSettings = ::onClickSettings, fabState = FabUiState( icon = FabUiState.FabIcon.ADD, diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/bookmark/BookmarkListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/bookmark/BookmarkListViewModel.kt new file mode 100644 index 000000000..92add8eeb --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/bookmark/BookmarkListViewModel.kt @@ -0,0 +1,166 @@ +package world.respect.shared.viewmodel.bookmark + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import world.respect.datalayer.school.model.Bookmark +import world.respect.shared.viewmodel.RespectViewModel +import org.koin.core.component.KoinScopeComponent +import org.koin.core.component.inject +import org.koin.core.scope.Scope +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.DataLoadingState +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.ext.map +import world.respect.datalayer.school.BookmarkDataSource +import world.respect.datalayer.school.model.BookmarkDetails +import world.respect.datalayer.school.model.StatusEnum +import world.respect.lib.opds.model.OpdsPublication +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.home +import world.respect.shared.generated.resources.remove_bookmark +import world.respect.shared.generated.resources.undo +import world.respect.shared.navigation.LearningUnitDetail +import world.respect.shared.navigation.NavCommand +import world.respect.shared.util.ext.asUiText +import world.respect.shared.util.ext.resolve +import world.respect.shared.viewmodel.app.appstate.Snack +import world.respect.shared.viewmodel.app.appstate.SnackBarDispatcher +import kotlin.getValue + +data class BookmarkListUiState( + val bookmarkDetails: List = emptyList(), + val app: DataLoadState = DataLoadingState(), +) + +class BookmarkListViewModel( + + savedStateHandle: SavedStateHandle, + private val accountManager: RespectAccountManager, + private val snackBarDispatcher: SnackBarDispatcher, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + private val _uiState = MutableStateFlow(BookmarkListUiState()) + + val uiState = _uiState.asStateFlow() + + override val scope: Scope = accountManager.requireActiveAccountScope() + private val schoolDataSource: SchoolDataSource by inject() + + init { + _appUiState.update { + it.copy(title = Res.string.home.asUiText()) + } + + viewModelScope.launch { + + val personUid = accountManager.activeAccount?.userGuid ?: return@launch + + schoolDataSource.bookmarkDataSource + .listAsFlow( + loadParams = DataLoadParams(), + listParams = BookmarkDataSource.GetListParams( + personUid = personUid + ) + ) + .collect { state -> + + val bookmarks = state.dataOrNull() ?: emptyList() + + loadMissingBookmarks(personUid) + + val bookmarkDetails = loadApps(bookmarks) + + _uiState.update { + it.copy(bookmarkDetails = bookmarkDetails) + } + } + } + } + + fun onClickRemoveBookmark(bookmark: Bookmark) { + viewModelScope.launch { + + val updatedBookmark = bookmark.copy( + status = StatusEnum.TO_BE_DELETED + ) + + schoolDataSource.bookmarkDataSource.store(listOf(updatedBookmark)) + + _uiState.update { + it.copy( + bookmarkDetails = it.bookmarkDetails.filterNot { b -> + b.bookmark.learningUnitManifestUrl == bookmark.learningUnitManifestUrl + } + ) + } + + snackBarDispatcher.showSnackBar( + Snack( + message = Res.string.remove_bookmark.asUiText(), + action = Res.string.undo.asUiText(), + onAction = { + viewModelScope.launch { + val restoredBookmark = bookmark.copy( + status = StatusEnum.ACTIVE + ) + schoolDataSource.bookmarkDataSource.store(listOf(restoredBookmark)) + } + } + ) + ) + } + } + + private suspend fun loadMissingBookmarks(personUid: String) { + val missingBookmarks = schoolDataSource.bookmarkDataSource + .findBookmarksWithMissingPublication(personUid) + + missingBookmarks.forEach { bookmark -> + schoolDataSource.opdsPublicationDataSource.getByUrl( + url = bookmark.learningUnitManifestUrl, + params = DataLoadParams(), + referrerUrl = null, + expectedPublicationId = null + ) + } + } + + private suspend fun loadApps(bookmarks: List): List = + coroutineScope { + bookmarks.map { bookmark -> + async { + val app = schoolDataSource.opdsPublicationDataSource + .getByUrl( + url = bookmark.appManifestUrl, + params = DataLoadParams(), + referrerUrl = null, + expectedPublicationId = null + ).map { publication -> + publication.resolve(bookmark.appManifestUrl) + } + BookmarkDetails(bookmark, app) + } + }.awaitAll() + } + + fun onClickBookmark(bookmark: Bookmark) { + _navCommandFlow.tryEmit( + value = NavCommand.Navigate( + LearningUnitDetail.create( + learningUnitManifestUrl = bookmark.learningUnitManifestUrl, + appManifestUrl = bookmark.appManifestUrl, + ) + ) + ) + } +} + diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/detail/LearningUnitDetailViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/detail/LearningUnitDetailViewModel.kt index bf4d1b79a..f727725cb 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/detail/LearningUnitDetailViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/detail/LearningUnitDetailViewModel.kt @@ -18,11 +18,13 @@ import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState import world.respect.datalayer.DataLoadingState import world.respect.datalayer.DataReadyState -import world.respect.datalayer.RespectAppDataSource import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.ext.map import world.respect.lib.opds.model.OpdsPublication import world.respect.datalayer.respect.model.LEARNING_UNIT_MIME_TYPES +import world.respect.datalayer.school.model.Bookmark +import world.respect.datalayer.school.model.StatusEnum import world.respect.libutil.ext.resolve import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.launchapp.LaunchAppUseCase @@ -32,6 +34,7 @@ import world.respect.shared.util.ext.asUiText import world.respect.shared.util.ext.resolve import world.respect.shared.viewmodel.app.appstate.getTitle import world.respect.shared.viewmodel.learningunit.LearningUnitSelection +import kotlin.getValue data class LearningUnitDetailUiState( val lessonDetail: OpdsPublication? = null, @@ -39,6 +42,7 @@ data class LearningUnitDetailUiState( val pinState: PublicationPinState = PublicationPinState( PublicationPinState.Status.NOT_PINNED, 0, 0 ), + val isBookmarked: Boolean = false, ) { val buttonsEnabled: Boolean get() = lessonDetail != null @@ -46,8 +50,8 @@ data class LearningUnitDetailUiState( class LearningUnitDetailViewModel( savedStateHandle: SavedStateHandle, - private val appDataSource: RespectAppDataSource, private val launchAppUseCase: LaunchAppUseCase, + private val accountManager: RespectAccountManager, private val ustadCache: UstadCache, accountMananger: RespectAccountManager, ) : RespectViewModel(savedStateHandle), KoinScopeComponent { @@ -93,6 +97,18 @@ class LearningUnitDetailViewModel( } } + viewModelScope.launch { + val personUid = accountManager.activeAccount?.userGuid ?: return@launch + + schoolDataSource.bookmarkDataSource.getBookmarkStatus( + personUid, + route.learningUnitManifestUrl + ) + .collect { bookmarked -> + _uiState.update { it.copy(isBookmarked = bookmarked) } + } + } + viewModelScope.launch { schoolDataSource.opdsPublicationDataSource.getByUrlAsFlow( url = route.appManifestUrl, @@ -100,8 +116,14 @@ class LearningUnitDetailViewModel( referrerUrl = null, expectedPublicationId = null, ).collect { app -> - _uiState.update { it.copy(app = app) } - } + _uiState.update { + it.copy( + app = app.map { publication -> + publication.resolve(route.appManifestUrl) + } + ) + } + } } viewModelScope.launch { @@ -134,19 +156,21 @@ class LearningUnitDetailViewModel( fun onClickDownload() { viewModelScope.launch { try { - when(uiState.value.pinState.status) { + when (uiState.value.pinState.status) { PublicationPinState.Status.NOT_PINNED -> { ustadCache.pinPublication(route.learningUnitManifestUrl) } + PublicationPinState.Status.READY -> { ustadCache.unpinPublication(route.learningUnitManifestUrl) } + else -> { //Do nothing } } - }catch(t: Throwable) { + } catch (t: Throwable) { t.printStackTrace() } } @@ -168,4 +192,27 @@ class LearningUnitDetailViewModel( ) ) } + + fun onClickBookmark() { + viewModelScope.launch { + val personUid = accountManager.activeAccount?.userGuid ?: return@launch + val learningUnitManifestUrl = route.learningUnitManifestUrl + + val status = + if (uiState.value.isBookmarked) + StatusEnum.TO_BE_DELETED + else + StatusEnum.ACTIVE + + val bookmark = Bookmark( + personUid = personUid, + learningUnitManifestUrl = learningUnitManifestUrl, + status = status, + appManifestUrl= route.appManifestUrl + ) + + schoolDataSource.bookmarkDataSource.store(listOf(bookmark)) + } + } } + diff --git a/respect-server/src/main/kotlin/world/respect/server/Application.kt b/respect-server/src/main/kotlin/world/respect/server/Application.kt index 539c3633f..a7cffd08b 100644 --- a/respect-server/src/main/kotlin/world/respect/server/Application.kt +++ b/respect-server/src/main/kotlin/world/respect/server/Application.kt @@ -44,6 +44,7 @@ import world.respect.server.routes.passkey.VerifySignInWithPasskeyRoute import world.respect.server.routes.qrcode.PersonQrBadgeRoute import world.respect.server.routes.school.respect.AddChildAccountRoute import world.respect.server.routes.school.respect.AssignmentRoute +import world.respect.server.routes.school.respect.BookmarkRoute import world.respect.server.routes.school.respect.ClassRoute import world.respect.server.routes.school.respect.EnrollmentRoute import world.respect.server.routes.school.respect.InviteInfoRoute @@ -253,6 +254,7 @@ fun Application.module() { EnrollmentRoute() AssignmentRoute() PersonQrBadgeRoute() + BookmarkRoute() AddChildAccountRoute( addChildAccountUseCase = { it.requireAccountScope().get() } ) diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/BookmarkRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/BookmarkRoute.kt new file mode 100644 index 000000000..56aa3a572 --- /dev/null +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/BookmarkRoute.kt @@ -0,0 +1,48 @@ +package world.respect.server.routes.school.respect + +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receive +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.school.BookmarkDataSource +import world.respect.datalayer.school.ClassDataSource +import world.respect.datalayer.school.PersonPasskeyDataSource +import world.respect.server.util.ext.offsetLimitPagingLoadParams +import world.respect.server.util.ext.requireAccountScope +import world.respect.server.util.ext.respondDataLoadState +import world.respect.server.util.ext.respondOffsetLimitPaging + + +@Suppress("FunctionName") +fun Route.BookmarkRoute( + schoolDataSource: (ApplicationCall) -> SchoolDataSource = { call -> + call.requireAccountScope().get() + }, +) { + + get(BookmarkDataSource.ENDPOINT_NAME) { + val schoolDataSource = schoolDataSource(call) + call.respondDataLoadState( + schoolDataSource.bookmarkDataSource.list( + loadParams = DataLoadParams(), + listParams = BookmarkDataSource.GetListParams.fromParams( + call.request.queryParameters + ) + ) + ) + } + + post(BookmarkDataSource.ENDPOINT_NAME) { + schoolDataSource(call).bookmarkDataSource.store( + list = call.receive() + ) + call.respond(HttpStatusCode.NoContent) + } +} \ No newline at end of file diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/grade1.json b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/grade1.json index e77846479..9e6b9c095 100644 --- a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/grade1.json +++ b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/grade1.json @@ -4,7 +4,7 @@ }, "links": [ - {"rel": "self", "href": "grade1.json", "type": "application/opds+json"} + { "rel": "self", "href": "grade1.json", "type": "application/opds+json" } ], "publications": [ @@ -15,26 +15,49 @@ "author": "Mullah Nasruddin", "identifier": "http://example.app/id/lesson001", "language": "en", - "modified": "2015-09-29T17:00:00Z", - "subject": [ - { - "name": "Mathematics", - "scheme": "https://www.bisg.org/#bisac", - "code": "MAT000000" - } - ] + "modified": "2015-09-29T17:00:00Z" }, "links": [ - {"rel": "self", "href": "lesson001/lesson001.json", "type": "application/opds-publication+json"}, + { + "rel": "self", + "href": "lesson001/lesson001.json", + "type": "application/opds-publication+json" + }, + { + "rel": "http://opds-spec.org/acquisition/open-access", + "href": "$TESTBASEURL/grade1/lesson001/lesson001.html", + "type": "text/html" + } + ], + "images": [ + { "href": "lesson001/cover.png", "type": "image/png" } + ] + }, + + { + "metadata": { + "@type": "http://schema.org/Game", + "title": "Lesson 002", + "author": "Video Lesson", + "identifier": "http://example.app/id/lesson002", + "language": "en", + "modified": "2026-03-11T10:00:00Z" + }, + "links": [ + { + "rel": "self", + "href": "lesson002/lesson002.json", + "type": "application/opds-publication+json" + }, { "rel": "http://opds-spec.org/acquisition/open-access", - "href": "http://localhost/opds/case_valid/grade1/lesson001/lesson001.html", + "href": "$TESTBASEURL/grade1/lesson002/lesson002.html", "type": "text/html" } ], "images": [ - {"href": "lesson001/cover.png", "type": "image/png" } + { "href": "lesson002/cover.png", "type": "image/png" } ] } ] -} +} \ No newline at end of file diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/cover.png b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/cover.png new file mode 100644 index 000000000..f9a625b1c Binary files /dev/null and b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/cover.png differ diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.html b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.html new file mode 100644 index 000000000..d3850707a --- /dev/null +++ b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.html @@ -0,0 +1,17 @@ + + + + + Lesson 2 + + + + +

Lesson 2 Video

+ + + + + \ No newline at end of file diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.json b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.json new file mode 100644 index 000000000..ed6bc3c83 --- /dev/null +++ b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.json @@ -0,0 +1,31 @@ +{ + "metadata": { + "@type": "http://schema.org/Game", + "title": "Lesson 002", + "author": "Video Lesson", + "identifier": "https://example.app/id/lesson002", + "language": "en", + "modified": "2026-03-11T10:00:00Z" + }, + "links": [ + { + "rel": "self", + "href": "$TESTBASEURL/grade1/lesson002/lesson002.json", + "type": "application/opds-publication+json" + }, + { + "rel": "http://opds-spec.org/acquisition/open-access", + "href": "lesson002.html", + "type": "text/html" + } + ], + "images": [ + { "href": "cover.png", "type": "image/png" } + ], + "resources": [ + { + "href": "video.mp4", + "type": "video/mp4" + } + ] +} \ No newline at end of file diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/video.mp4 b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/video.mp4 new file mode 100644 index 000000000..694b4038e Binary files /dev/null and b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/video.mp4 differ