From 2b49d544c052b1d172760e8da392cd9908221d08 Mon Sep 17 00:00:00 2001 From: lipsa-b Date: Wed, 28 Jan 2026 15:18:29 +0530 Subject: [PATCH 1/6] initial commit --- .../view/apps/launcher/AppLauncherScreen.kt | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt index 98aade679..dcf8c25f9 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -47,6 +48,7 @@ import world.respect.datalayer.NoDataLoadedState import world.respect.datalayer.compatibleapps.model.RespectAppManifest import world.respect.datalayer.ext.dataOrNull import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.downloaded_lessons import world.respect.shared.generated.resources.empty import world.respect.shared.generated.resources.empty_list import world.respect.shared.generated.resources.more_info @@ -65,6 +67,7 @@ fun AppLauncherScreen( uiState = uiState, onClickApp = { viewModel.onClickApp(it) }, onClickRemove = { viewModel.onClickRemove(it) }, + onClickDownloads = { viewModel.onClickDownloads() }, ) } @@ -74,10 +77,29 @@ fun AppLauncherScreen( uiState: AppLauncherUiState, onClickApp: (DataLoadState) -> Unit, onClickRemove: (DataLoadState) -> Unit, + onClickDownloads: () -> Unit = {}, ) { val pager = respectRememberPager(uiState.apps) val lazyPagingItems = pager.flow.collectAsLazyPagingItems() - + Column( + modifier = Modifier.fillMaxSize() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onClickDownloads) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(resource = Res.string.downloaded_lessons), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } Box( modifier = Modifier.fillMaxSize() ) { From e8bd0329303981bc045afea367c8b0f67017d7c1 Mon Sep 17 00:00:00 2001 From: lipsa-b Date: Thu, 29 Jan 2026 09:59:28 +0530 Subject: [PATCH 2/6] create Downloadedlesson screens --- .../kotlin/world/respect/AppKoinModule.kt | 2 + .../kotlin/world/respect/app/app/AppBar.kt | 13 ++ .../world/respect/app/app/AppNavHost.kt | 10 + .../view/apps/launcher/AppLauncherScreen.kt | 32 +--- .../downloaded/DownloadedLessonsScreen.kt | 174 ++++++++++++++++++ .../composeResources/values/strings.xml | 4 + .../respect/shared/navigation/AppRoutes.kt | 2 + .../viewmodel/app/appstate/AppUiState.kt | 2 + .../apps/launcher/AppLauncherViewModel.kt | 16 +- 9 files changed, 223 insertions(+), 32 deletions(-) create mode 100644 respect-app-compose/src/commonMain/kotlin/world/respect/app/view/lessonslist/downloaded/DownloadedLessonsScreen.kt 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 d18c2f0b0..9649b93f0 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt @@ -225,6 +225,7 @@ import world.respect.shared.viewmodel.schooldirectory.edit.SchoolDirectoryEditVi import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryListViewModel import world.respect.shared.viewmodel.scanqrcode.ScanQRCodeViewModel import world.respect.sharedse.domain.account.authenticatepassword.AuthenticateQrBadgeUseCaseDbImpl +import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonsViewModel const val SHARED_PREF_SETTINGS_NAME = "respect_settings3_" @@ -345,6 +346,7 @@ val appKoinModule = module { viewModelOf(::EnrollmentListViewModel) viewModelOf(::EnrollmentEditViewModel) viewModelOf(::CreateAccountSetPasswordViewModel) + viewModelOf(::DownloadedLessonsViewModel) single { diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppBar.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppBar.kt index 9e14feb9f..61966955e 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppBar.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppBar.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings @@ -54,6 +55,7 @@ import world.respect.shared.generated.resources.search import world.respect.shared.generated.resources.settings import world.respect.shared.util.ext.fullName import world.respect.datalayer.db.school.ext.fullName +import world.respect.shared.generated.resources.downloaded_lessons import world.respect.shared.generated.resources.more_options import world.respect.shared.util.ext.isLoading import world.respect.shared.viewmodel.app.appstate.AppActionButton @@ -209,6 +211,17 @@ fun RespectAppBar( ) } } + if (appUiState.downloadIconVisible == true) { + IconButton( + onClick = appUiState.onClickDownload ?: {}, + modifier = Modifier.testTag("download_icon") + ) { + Icon( + Icons.Default.Download, + contentDescription = stringResource(Res.string.downloaded_lessons) + ) + } + } if (appUiState.settingsIconVisible == true) { IconButton( 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 19203adab..8914c2eb2 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 @@ -23,6 +23,7 @@ import world.respect.app.view.enrollment.edit.EnrollmentEditScreen import world.respect.app.view.enrollment.list.EnrollmentListScreen import world.respect.app.view.learningunit.detail.LearningUnitDetailScreen import world.respect.app.view.learningunit.list.LearningUnitListScreen +import world.respect.app.view.lessonslist.downloaded.DownloadedLessonsScreen import world.respect.app.view.manageuser.accountlist.AccountListScreen import world.respect.app.view.manageuser.confirmation.ConfirmationScreen import world.respect.app.view.manageuser.createaccount.CreateAccountScreen @@ -106,6 +107,7 @@ import world.respect.shared.navigation.SchoolDirectoryEdit import world.respect.shared.navigation.SchoolDirectoryList import world.respect.shared.navigation.CreateAccountSetPassword import world.respect.shared.navigation.CreateAccountSetUsername +import world.respect.shared.navigation.DownloadedLessons import world.respect.shared.navigation.Settings import world.respect.shared.navigation.SignupScreen import world.respect.shared.navigation.TermsAndCondition @@ -125,6 +127,7 @@ import world.respect.shared.viewmodel.enrollment.edit.EnrollmentEditViewModel import world.respect.shared.viewmodel.enrollment.list.EnrollmentListViewModel import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel +import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonsViewModel import world.respect.shared.viewmodel.manageuser.confirmation.ConfirmationViewModel import world.respect.shared.viewmodel.manageuser.enterpasswordsignup.EnterPasswordSignupViewModel import world.respect.shared.viewmodel.manageuser.getstarted.GetStartedViewModel @@ -542,6 +545,13 @@ fun AppNavHost( viewModel = viewModel ) } + composable { + val viewModel: DownloadedLessonsViewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController + ) + DownloadedLessonsScreen(viewModel) + } composable { ScanQRCodeScreen( viewModel = respectViewModel( diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt index dcf8c25f9..4f85b9b55 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -48,7 +47,6 @@ import world.respect.datalayer.NoDataLoadedState import world.respect.datalayer.compatibleapps.model.RespectAppManifest import world.respect.datalayer.ext.dataOrNull import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.downloaded_lessons import world.respect.shared.generated.resources.empty import world.respect.shared.generated.resources.empty_list import world.respect.shared.generated.resources.more_info @@ -65,41 +63,20 @@ fun AppLauncherScreen( AppLauncherScreen( uiState = uiState, - onClickApp = { viewModel.onClickApp(it) }, - onClickRemove = { viewModel.onClickRemove(it) }, - onClickDownloads = { viewModel.onClickDownloads() }, + onClickApp = viewModel::onClickApp, + onClickRemove = viewModel::onClickRemove, ) } - @Composable fun AppLauncherScreen( uiState: AppLauncherUiState, onClickApp: (DataLoadState) -> Unit, onClickRemove: (DataLoadState) -> Unit, - onClickDownloads: () -> Unit = {}, ) { val pager = respectRememberPager(uiState.apps) val lazyPagingItems = pager.flow.collectAsLazyPagingItems() - Column( - modifier = Modifier.fillMaxSize() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onClickDownloads) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringResource(resource = Res.string.downloaded_lessons), - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - } + Box( modifier = Modifier.fillMaxSize() ) { @@ -137,7 +114,6 @@ fun AppLauncherScreen( verticalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - items( count = lazyPagingItems.itemCount, key = { index -> @@ -255,4 +231,4 @@ fun AppGridItem( Text(text = "-") } } -} +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/lessonslist/downloaded/DownloadedLessonsScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/lessonslist/downloaded/DownloadedLessonsScreen.kt new file mode 100644 index 000000000..adb575246 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/lessonslist/downloaded/DownloadedLessonsScreen.kt @@ -0,0 +1,174 @@ +package world.respect.app.view.lessonslist.downloaded + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import world.respect.app.app.RespectAsyncImage +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.delete +import world.respect.shared.viewmodel.app.appstate.getTitle +import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonItem +import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonsUiState +import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonsViewModel + +@Composable +fun DownloadedLessonsScreen( + viewModel: DownloadedLessonsViewModel +) { + val uiState by viewModel.uiState.collectAsState() + + DownloadedLessonsScreen( + uiState = uiState, + onClickLesson = viewModel::onClickLesson, + onClickDelete = viewModel::onClickDelete, + ) +} + +@Composable +fun DownloadedLessonsScreen( + uiState: DownloadedLessonsUiState, + onClickLesson: (DownloadedLessonItem) -> Unit, + onClickDelete: (DownloadedLessonItem) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 8.dp) + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Sort by", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.size(4.dp)) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + + items( + items = uiState.lessons, + key = { it.url } + ) { lesson -> + DownloadedLessonListItem( + lesson = lesson, + onClick = { onClickLesson(lesson) }, + onClickDelete = { onClickDelete(lesson) } + ) + } + } +} + +@Composable +private fun DownloadedLessonListItem( + lesson: DownloadedLessonItem, + onClick: () -> Unit, + onClickDelete: () -> Unit, +) { + ListItem( + headlineContent = { + Text( + text = lesson.publication?.metadata?.title?.getTitle().orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + lesson.publication?.metadata?.subtitle?.getTitle()?.let { subtitle -> + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + val metadataItems = buildList { + lesson.publication?.metadata?.language?.firstOrNull()?.let { lang -> + add(lang) + } + + lesson.publication?.metadata?.subject?.firstOrNull()?.let { subject -> + add(subject) + } + } + + if (metadataItems.isNotEmpty()) { + Text( + text = metadataItems.joinToString(separator = " • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + leadingContent = { + val iconUrl = lesson.publication?.images?.firstOrNull()?.href + + Box( + modifier = Modifier.size(64.dp) + ) { + RespectAsyncImage( + uri = iconUrl, + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + }, + trailingContent = { + IconButton(onClick = onClickDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(Res.string.delete), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml index 8d28a7aa2..aecba00a2 100644 --- a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml +++ b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml @@ -522,5 +522,9 @@ Section name Not set + Downloaded lessons + Undo + 1 deleted + 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 8cb272c4f..5406cd1dd 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 @@ -44,6 +44,8 @@ data class JoinClazzWithCode( } } +@Serializable +object DownloadedLessons : RespectAppRoute @Serializable object Onboarding : RespectAppRoute diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/app/appstate/AppUiState.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/app/appstate/AppUiState.kt index 319b36334..3105a801e 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/app/appstate/AppUiState.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/app/appstate/AppUiState.kt @@ -20,6 +20,8 @@ data class AppUiState( val hideBottomNavigation: Boolean = false, val hideSettingsIcon: Boolean = false, val userAccountIconVisible: Boolean? = null, + val downloadIconVisible: Boolean? = null, + val onClickDownload: (() -> Unit)? = null, val hideAppBar: Boolean = false, val appBarColors: AppBarColors = AppBarColors.STANDARD, val actions: List = emptyList(), 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 8a27782c0..da29132a9 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 @@ -41,6 +41,7 @@ import world.respect.shared.navigation.RespectAppList import world.respect.shared.resources.UiText import world.respect.shared.util.ext.asUiText import world.respect.datalayer.db.school.ext.isAdmin +import world.respect.shared.navigation.DownloadedLessons import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.FabUiState @@ -48,7 +49,7 @@ data class AppLauncherUiState( val apps : IPagingSourceFactory = EmptyPagingSourceFactory(), val respectAppForSchoolApp: (SchoolApp) -> Flow> = { emptyFlow() }, val canRemove: Boolean = false, - val emptyListDescription: UiText?=null, + val emptyListDescription: UiText? = null, ) class AppLauncherViewModel( @@ -82,6 +83,8 @@ class AppLauncherViewModel( it.copy( title = Res.string.apps.asUiText(), onClickSettings = ::onClickSettings, + downloadIconVisible = true, + onClickDownload = ::onClickDownloads, fabState = FabUiState( icon = FabUiState.FabIcon.ADD, text = Res.string.app.asUiText(), @@ -103,7 +106,6 @@ class AppLauncherViewModel( respectAppForSchoolApp = this@AppLauncherViewModel::respectAppForSchoolApp, apps = pagingSourceHolder ) - } viewModelScope.launch { @@ -152,12 +154,19 @@ class AppLauncherViewModel( ) ) } + fun onClickSettings() { _navCommandFlow.tryEmit( NavCommand.Navigate(Settings) ) } + fun onClickDownloads() { + _navCommandFlow.tryEmit( + NavCommand.Navigate(DownloadedLessons) + ) + } + fun onClickRemove(app: DataLoadState) { val manifestUrl = app.metaInfo.url ?: return viewModelScope.launch { @@ -179,5 +188,4 @@ class AppLauncherViewModel( DataLoadParams() ) } -} - +} \ No newline at end of file From 87073de17d55761591b09f2c2f35d75d10cfd100 Mon Sep 17 00:00:00 2001 From: lipsa-b Date: Mon, 2 Feb 2026 09:42:08 +0530 Subject: [PATCH 3/6] create Downloadedlesson screens in existing lessonlist --- .../kotlin/world/respect/AppKoinModule.kt | 2 - .../kotlin/world/respect/app/app/App.kt | 26 +- .../world/respect/app/app/AppNavHost.kt | 10 - .../view/apps/launcher/AppLauncherScreen.kt | 2 + .../list/LearningUnitListScreen.kt | 197 ++++++--- .../downloaded/DownloadedLessonsScreen.kt | 174 -------- .../respect/shared/navigation/AppRoutes.kt | 8 +- .../apps/launcher/AppLauncherViewModel.kt | 40 +- .../list/LearningUnitListViewModel.kt | 403 +++++++++++++++--- 9 files changed, 540 insertions(+), 322 deletions(-) delete mode 100644 respect-app-compose/src/commonMain/kotlin/world/respect/app/view/lessonslist/downloaded/DownloadedLessonsScreen.kt 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 9649b93f0..d18c2f0b0 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt @@ -225,7 +225,6 @@ import world.respect.shared.viewmodel.schooldirectory.edit.SchoolDirectoryEditVi import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryListViewModel import world.respect.shared.viewmodel.scanqrcode.ScanQRCodeViewModel import world.respect.sharedse.domain.account.authenticatepassword.AuthenticateQrBadgeUseCaseDbImpl -import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonsViewModel const val SHARED_PREF_SETTINGS_NAME = "respect_settings3_" @@ -346,7 +345,6 @@ val appKoinModule = module { viewModelOf(::EnrollmentListViewModel) viewModelOf(::EnrollmentEditViewModel) viewModelOf(::CreateAccountSetPasswordViewModel) - viewModelOf(::DownloadedLessonsViewModel) single { 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 f95a389f2..c5db08d78 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 @@ -29,6 +29,8 @@ import androidx.compose.material.icons.filled.ImportContacts import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Icon import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.compose.rememberNavController @@ -156,17 +158,23 @@ 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 uiText = snack.message + val message = when (uiText) { + is StringUiText -> uiText.text + is StringResourceUiText -> getString(uiText.resource) + else -> "" } - snackbarHostState.showSnackbar(message, it.action) + val result = snackbarHostState.showSnackbar( + message = message, + actionLabel = snack.action, + 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 8914c2eb2..19203adab 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 @@ -23,7 +23,6 @@ import world.respect.app.view.enrollment.edit.EnrollmentEditScreen import world.respect.app.view.enrollment.list.EnrollmentListScreen import world.respect.app.view.learningunit.detail.LearningUnitDetailScreen import world.respect.app.view.learningunit.list.LearningUnitListScreen -import world.respect.app.view.lessonslist.downloaded.DownloadedLessonsScreen import world.respect.app.view.manageuser.accountlist.AccountListScreen import world.respect.app.view.manageuser.confirmation.ConfirmationScreen import world.respect.app.view.manageuser.createaccount.CreateAccountScreen @@ -107,7 +106,6 @@ import world.respect.shared.navigation.SchoolDirectoryEdit import world.respect.shared.navigation.SchoolDirectoryList import world.respect.shared.navigation.CreateAccountSetPassword import world.respect.shared.navigation.CreateAccountSetUsername -import world.respect.shared.navigation.DownloadedLessons import world.respect.shared.navigation.Settings import world.respect.shared.navigation.SignupScreen import world.respect.shared.navigation.TermsAndCondition @@ -127,7 +125,6 @@ import world.respect.shared.viewmodel.enrollment.edit.EnrollmentEditViewModel import world.respect.shared.viewmodel.enrollment.list.EnrollmentListViewModel import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel -import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonsViewModel import world.respect.shared.viewmodel.manageuser.confirmation.ConfirmationViewModel import world.respect.shared.viewmodel.manageuser.enterpasswordsignup.EnterPasswordSignupViewModel import world.respect.shared.viewmodel.manageuser.getstarted.GetStartedViewModel @@ -545,13 +542,6 @@ fun AppNavHost( viewModel = viewModel ) } - composable { - val viewModel: DownloadedLessonsViewModel = respectViewModel( - onSetAppUiState = onSetAppUiState, - navController = respectNavController - ) - DownloadedLessonsScreen(viewModel) - } composable { ScanQRCodeScreen( viewModel = respectViewModel( diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt index 4f85b9b55..0765c6ae5 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt @@ -65,6 +65,7 @@ fun AppLauncherScreen( uiState = uiState, onClickApp = viewModel::onClickApp, onClickRemove = viewModel::onClickRemove, + onClickDownload = viewModel::onClickDownloads ) } @@ -73,6 +74,7 @@ fun AppLauncherScreen( uiState: AppLauncherUiState, onClickApp: (DataLoadState) -> Unit, onClickRemove: (DataLoadState) -> Unit, + onClickDownload: () -> Unit ) { val pager = respectRememberPager(uiState.apps) val lazyPagingItems = pager.flow.collectAsLazyPagingItems() diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/list/LearningUnitListScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/list/LearningUnitListScreen.kt index 71cabda51..fc7ed3198 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/list/LearningUnitListScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/list/LearningUnitListScreen.kt @@ -12,12 +12,16 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -27,20 +31,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.classes -import world.respect.shared.generated.resources.duration import world.respect.app.app.RespectAsyncImage import world.respect.app.components.RespectListSortHeader import world.respect.app.components.defaultItemPadding +import world.respect.lib.opds.model.OpdsPublication +import world.respect.lib.opds.model.ReadiumLink +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.classes +import world.respect.shared.generated.resources.delete +import world.respect.shared.generated.resources.duration +import world.respect.shared.util.SortOrderOption import world.respect.shared.viewmodel.app.appstate.getTitle +import world.respect.shared.viewmodel.learningunit.LearningUnitSelection import world.respect.shared.viewmodel.learningunit.list.LearningUnitListUiState import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel -import world.respect.shared.util.SortOrderOption -import world.respect.lib.opds.model.OpdsPublication -import world.respect.lib.opds.model.ReadiumLink import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel.Companion.ICON + @Composable fun LearningUnitListScreen( viewModel: LearningUnitListViewModel @@ -51,7 +58,9 @@ fun LearningUnitListScreen( uiState = uiState, onSortOrderChanged = viewModel::onSortOrderChanged, onClickPublication = { viewModel.onClickPublication(it) }, - onClickNavigation = { viewModel.onClickNavigation(it) } + onClickNavigation = { viewModel.onClickNavigation(it) }, + onClickDownloadedLesson = { viewModel.onClickDownloadedLesson(it) }, + onClickDeleteDownloaded = { viewModel.onClickDeleteDownloaded(it) } ) } @@ -60,85 +69,145 @@ fun LearningUnitListScreen( uiState: LearningUnitListUiState, @Suppress("unused") onSortOrderChanged: (SortOrderOption) -> Unit = { }, onClickPublication: (OpdsPublication) -> Unit, - onClickNavigation: (ReadiumLink) -> Unit - + onClickNavigation: (ReadiumLink) -> Unit, + onClickDownloadedLesson: (LearningUnitSelection) -> Unit, + onClickDeleteDownloaded: (LearningUnitSelection) -> Unit ) { - Column( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - itemsIndexed( - items = uiState.navigation, - key = { index, navigation -> - navigation.href - } - ) { index, navigation -> - NavigationListItem( - navigation, - onClickNavigation = { - onClickNavigation(navigation) - } - ) - } + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn(verticalArrangement = Arrangement.spacedBy(16.dp)) { - itemsIndexed( - items = uiState.publications, - key = { index, publications -> - publications.metadata.identifier.toString() + if (uiState.showOnlyDownloaded) { + item(key = "sort_header") { + RespectListSortHeader( + modifier = Modifier + .fillMaxWidth() + .defaultItemPadding(), + sortOptions = uiState.sortOptions, + activeSortOrderOption = uiState.activeSortOrderOption, + enabled = uiState.fieldsEnabled, + onClickSortOption = onSortOrderChanged + ) } - ) { index, publication -> - PublicationListItem( - publication, - onClickPublication = { - onClickPublication(publication) - } - ) - } - uiState.group.forEach { group -> - item { - ListItem( - headlineContent = { - Text( - text = group.metadata.title, - ) - } + itemsIndexed( + items = uiState.downloadedLessons, + key = { index, item -> item.learningUnitManifestUrl.toString() } + ) { index, item -> + DownloadedLessonListItem( + item = item, + onClickLesson = { onClickDownloadedLesson(item) }, + onClickDelete = { onClickDeleteDownloaded(item) } ) } - + } else { itemsIndexed( - items = group.navigation ?: emptyList(), - key = { index, navigation -> - navigation.href - } + items = uiState.navigation, + key = { index, navigation -> navigation.href } ) { index, navigation -> NavigationListItem( navigation, - onClickNavigation = { - onClickNavigation(navigation) - } + onClickNavigation = { onClickNavigation(navigation) } ) } + itemsIndexed( - items = group.publications ?: emptyList(), - key = { index, publication -> - publication.metadata.identifier.toString() - } + items = uiState.publications, + key = { index, publications -> publications.metadata.identifier.toString() } ) { index, publication -> PublicationListItem( publication, - onClickPublication = { - onClickPublication(publication) - } + onClickPublication = { onClickPublication(publication) } ) } + uiState.group.forEach { group -> + item { + ListItem( + headlineContent = { Text(text = group.metadata.title) } + ) + } + + itemsIndexed( + items = group.navigation ?: emptyList(), + key = { index, navigation -> navigation.href } + ) { index, navigation -> + NavigationListItem( + navigation, + onClickNavigation = { onClickNavigation(navigation) } + ) + } + + itemsIndexed( + items = group.publications ?: emptyList(), + key = { index, publication -> publication.metadata.identifier.toString() } + ) { index, publication -> + PublicationListItem( + publication, + onClickPublication = { onClickPublication(publication) } + ) + } + } } } } +} +@Composable +fun DownloadedLessonListItem( + item: LearningUnitSelection, + onClickLesson: () -> Unit, + onClickDelete: () -> Unit +) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .clickable { onClickLesson() }, + leadingContent = { + val iconUrl = item.selectedPublication.images?.firstOrNull()?.href + + Box( + modifier = Modifier + .fillMaxHeight() + .width(48.dp), + contentAlignment = Alignment.Center + ) { + iconUrl.also { icon -> + RespectAsyncImage( + uri = icon, + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier.size(36.dp) + ) + } + } + }, + headlineContent = { + Text(text = item.selectedPublication.metadata.title.getTitle()) + }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(text = stringResource(Res.string.classes)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item.selectedPublication.metadata.language?.let { language -> + Text(text = language.joinToString(", ")) + } + item.selectedPublication.metadata.duration?.let { duration -> + Text(text = "${stringResource(Res.string.duration)} - $duration") + } + } + } + }, + trailingContent = { + IconButton(onClick = onClickDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(Res.string.delete), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) } @Composable diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/lessonslist/downloaded/DownloadedLessonsScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/lessonslist/downloaded/DownloadedLessonsScreen.kt deleted file mode 100644 index adb575246..000000000 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/lessonslist/downloaded/DownloadedLessonsScreen.kt +++ /dev/null @@ -1,174 +0,0 @@ -package world.respect.app.view.lessonslist.downloaded - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import world.respect.app.app.RespectAsyncImage -import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.delete -import world.respect.shared.viewmodel.app.appstate.getTitle -import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonItem -import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonsUiState -import world.respect.shared.viewmodel.lessonslist.downloaded.DownloadedLessonsViewModel - -@Composable -fun DownloadedLessonsScreen( - viewModel: DownloadedLessonsViewModel -) { - val uiState by viewModel.uiState.collectAsState() - - DownloadedLessonsScreen( - uiState = uiState, - onClickLesson = viewModel::onClickLesson, - onClickDelete = viewModel::onClickDelete, - ) -} - -@Composable -fun DownloadedLessonsScreen( - uiState: DownloadedLessonsUiState, - onClickLesson: (DownloadedLessonItem) -> Unit, - onClickDelete: (DownloadedLessonItem) -> Unit, -) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(vertical = 8.dp) - ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Sort by", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.size(4.dp)) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - } - } - - items( - items = uiState.lessons, - key = { it.url } - ) { lesson -> - DownloadedLessonListItem( - lesson = lesson, - onClick = { onClickLesson(lesson) }, - onClickDelete = { onClickDelete(lesson) } - ) - } - } -} - -@Composable -private fun DownloadedLessonListItem( - lesson: DownloadedLessonItem, - onClick: () -> Unit, - onClickDelete: () -> Unit, -) { - ListItem( - headlineContent = { - Text( - text = lesson.publication?.metadata?.title?.getTitle().orEmpty(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - lesson.publication?.metadata?.subtitle?.getTitle()?.let { subtitle -> - if (subtitle.isNotEmpty()) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - val metadataItems = buildList { - lesson.publication?.metadata?.language?.firstOrNull()?.let { lang -> - add(lang) - } - - lesson.publication?.metadata?.subject?.firstOrNull()?.let { subject -> - add(subject) - } - } - - if (metadataItems.isNotEmpty()) { - Text( - text = metadataItems.joinToString(separator = " • "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - }, - leadingContent = { - val iconUrl = lesson.publication?.images?.firstOrNull()?.href - - Box( - modifier = Modifier.size(64.dp) - ) { - RespectAsyncImage( - uri = iconUrl, - contentDescription = "", - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - } - }, - trailingContent = { - IconButton(onClick = onClickDelete) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(Res.string.delete), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - ) -} \ 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 5406cd1dd..ad604b105 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 @@ -44,9 +44,6 @@ data class JoinClazzWithCode( } } -@Serializable -object DownloadedLessons : RespectAppRoute - @Serializable object Onboarding : RespectAppRoute @@ -285,6 +282,7 @@ class LearningUnitList( private val opdsFeedUrlStr: String, private val appManifestUrlStr: String, private val resultDestStr: String?, + val showOnlyDownloaded: Boolean = false, ) : RespectAppRoute, RouteWithResultDest { @Transient @@ -302,11 +300,13 @@ class LearningUnitList( opdsFeedUrl: Url, appManifestUrl: Url, resultDest: ResultDest? = null, + showOnlyDownloaded: Boolean = false, ): LearningUnitList { return LearningUnitList( opdsFeedUrlStr = opdsFeedUrl.toString(), appManifestUrlStr = appManifestUrl.toString(), - resultDestStr = resultDest.encodeToJsonStringOrNull() + resultDestStr = resultDest.encodeToJsonStringOrNull(), + showOnlyDownloaded = showOnlyDownloaded ) } 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 da29132a9..e1470bcf0 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 @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinScopeComponent @@ -41,7 +42,6 @@ import world.respect.shared.navigation.RespectAppList import world.respect.shared.resources.UiText import world.respect.shared.util.ext.asUiText import world.respect.datalayer.db.school.ext.isAdmin -import world.respect.shared.navigation.DownloadedLessons import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.FabUiState @@ -160,13 +160,41 @@ class AppLauncherViewModel( NavCommand.Navigate(Settings) ) } - fun onClickDownloads() { - _navCommandFlow.tryEmit( - NavCommand.Navigate(DownloadedLessons) - ) - } + viewModelScope.launch { + try { + val firstApp = schoolDataSource.schoolAppDataSource.list( + loadParams = DataLoadParams(), + params = SchoolAppDataSource.GetListParams() + ).dataOrNull()?.firstOrNull() + + if (firstApp != null) { + val appManifest = appDataSource.compatibleAppsDataSource.getAppAsFlow( + manifestUrl = firstApp.appManifestUrl, + loadParams = DataLoadParams() + ).first().dataOrNull() + + if (appManifest != null) { + val learningUnitsUrl = firstApp.appManifestUrl.resolve( + appManifest.learningUnits.toString() + ) + _navCommandFlow.tryEmit( + NavCommand.Navigate( + LearningUnitList.create( + opdsFeedUrl = learningUnitsUrl, + appManifestUrl = firstApp.appManifestUrl, + showOnlyDownloaded = true + ) + ) + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } fun onClickRemove(app: DataLoadState) { val manifestUrl = app.metaInfo.url ?: return viewModelScope.launch { diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt index 6cb93bf4a..b9f8cef18 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt @@ -3,35 +3,54 @@ package world.respect.shared.viewmodel.learningunit.list import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.ustadmobile.libcache.PublicationPinState +import com.ustadmobile.libcache.UstadCache import io.ktor.http.Url +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import world.respect.shared.navigation.LearningUnitDetail -import world.respect.shared.navigation.LearningUnitList -import world.respect.shared.viewmodel.app.appstate.AppBarSearchUiState -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.DataReadyState import world.respect.datalayer.RespectAppDataSource +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.SchoolAppDataSource import world.respect.lib.opds.model.OpdsFacet import world.respect.lib.opds.model.OpdsGroup import world.respect.lib.opds.model.OpdsPublication import world.respect.lib.opds.model.ReadiumLink import world.respect.libutil.ext.resolve +import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.downloaded_lessons +import world.respect.shared.generated.resources.item_deleted import world.respect.shared.generated.resources.language +import world.respect.shared.generated.resources.sort_by +import world.respect.shared.navigation.LearningUnitDetail +import world.respect.shared.navigation.LearningUnitList import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.NavResultReturner import world.respect.shared.navigation.sendResultIfResultExpected import world.respect.shared.util.SortOrderOption import world.respect.shared.util.ext.asUiText import world.respect.shared.util.ext.resolve +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.viewmodel.app.appstate.AppBarSearchUiState +import world.respect.shared.viewmodel.app.appstate.Snack +import world.respect.shared.viewmodel.app.appstate.SnackBarDispatcher import world.respect.shared.viewmodel.learningunit.LearningUnitSelection data class LearningUnitListUiState( val publications: List = emptyList(), + val downloadedLessons: List = emptyList(), val navigation: List = emptyList(), val group: List = emptyList(), val facetOptions: List = emptyList(), @@ -40,68 +59,352 @@ data class LearningUnitListUiState( val activeSortOrderOption: SortOrderOption = SortOrderOption( Res.string.language, 1, true ), - val fieldsEnabled: Boolean = true + val fieldsEnabled: Boolean = true, + val showOnlyDownloaded: Boolean = false, + val showDeleteButton: Boolean = false, + val isLoadingDownloaded: Boolean = false ) class LearningUnitListViewModel( savedStateHandle: SavedStateHandle, private val appDataSource: RespectAppDataSource, private val resultReturner: NavResultReturner, -) : RespectViewModel(savedStateHandle) { + private val ustadCache: UstadCache, + private val snackBarDispatcher: SnackBarDispatcher, + accountManager: RespectAccountManager, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { - private val _uiState = MutableStateFlow(LearningUnitListUiState()) + override val scope: Scope = accountManager.requireActiveAccountScope() + private val schoolDataSource: SchoolDataSource by inject() + private val _uiState = MutableStateFlow(LearningUnitListUiState()) val uiState = _uiState.asStateFlow() - private val route: LearningUnitList = savedStateHandle.toRoute() init { viewModelScope.launch { _appUiState.update { it.copy( - searchState = AppBarSearchUiState( - visible = true - ) + searchState = AppBarSearchUiState(visible = !route.showOnlyDownloaded), + title = if (route.showOnlyDownloaded) { + Res.string.downloaded_lessons.asUiText() + } else { + it.title + }, + showBackButton = true ) } - appDataSource.opdsDataSource.loadOpdsFeed( - url = route.opdsFeedUrl, - params = DataLoadParams() - ).collect { result -> - when (result) { - is DataReadyState -> { - - val resolvedFeed = result.data.resolve(route.opdsFeedUrl) - val appBarTitle = result.data.metadata.title - val facetOptions = result.data.facets ?: emptyList() - val sortOptions = facetOptions.mapIndexed { index, facet -> - SortOrderOption( - fieldMessageId = Res.string.language, - flag = index + 1, - order = true - ) - } + if (route.showOnlyDownloaded) { + loadAllDownloadedLessons() + } else { + loadFromSingleFeed() + } + } + } - _appUiState.update { - it.copy( - title = appBarTitle.asUiText(), - searchState = AppBarSearchUiState(visible = true) - ) - } + private suspend fun loadFromSingleFeed() { + appDataSource.opdsDataSource.loadOpdsFeed( + url = route.opdsFeedUrl, + params = DataLoadParams() + ).collect { result -> + when (result) { + is DataReadyState -> { + val resolvedFeed = result.data.resolve(route.opdsFeedUrl) + val appBarTitle = result.data.metadata.title + val facetOptions = result.data.facets ?: emptyList() + val sortOptions = facetOptions.mapIndexed { index, facet -> + SortOrderOption( + fieldMessageId = Res.string.language, + flag = index + 1, + order = true + ) + } + + _appUiState.update { + it.copy( + title = appBarTitle.asUiText(), + searchState = AppBarSearchUiState(visible = true) + ) + } + + _uiState.update { + it.copy( + navigation = resolvedFeed.navigation ?: emptyList(), + publications = resolvedFeed.publications ?: emptyList(), + group = resolvedFeed.groups ?: emptyList(), + facetOptions = facetOptions, + sortOptions = sortOptions, + showOnlyDownloaded = false, + showDeleteButton = false, + downloadedLessons = emptyList() + ) + } + } + else -> {} + } + } + } + + private suspend fun loadAllDownloadedLessons() { + try { + _uiState.update { + it.copy(isLoadingDownloaded = true) + } + + val sortOptions = createDownloadedLessonsSortOptions() + + _uiState.update { + it.copy( + downloadedLessons = emptyList(), + publications = emptyList(), + navigation = emptyList(), + group = emptyList(), + showOnlyDownloaded = true, + showDeleteButton = true, + sortOptions = sortOptions, + activeSortOrderOption = sortOptions.first(), + facetOptions = emptyList() + ) + } + + val downloadedLessons = mutableListOf() + val seenManifestUrls = mutableSetOf() + val lock = Any() + + val schoolAppsResult = schoolDataSource.schoolAppDataSource.list( + loadParams = DataLoadParams(), + params = SchoolAppDataSource.GetListParams() + ) + + val allApps = schoolAppsResult.dataOrNull() + + if (allApps == null) { + _uiState.update { it.copy(isLoadingDownloaded = false) } + return + } + + if (allApps.isEmpty()) { + _uiState.update { it.copy(isLoadingDownloaded = false) } + return + } + + allApps.map { app -> + viewModelScope.async { + try { + processAppForDownloadedLessons( + app = app, + downloadedLessons = downloadedLessons, + seenManifestUrls = seenManifestUrls, + lock = lock + ) + } catch (e: Exception) { + e.printStackTrace() + } + } + }.awaitAll() + + _uiState.update { + it.copy( + downloadedLessons = downloadedLessons.toList(), + isLoadingDownloaded = false + ) + } + + } catch (e: Exception) { + e.printStackTrace() + _uiState.update { it.copy(isLoadingDownloaded = false) } + } + } + + private fun createDownloadedLessonsSortOptions(): List { + return listOf( + SortOrderOption( + fieldMessageId = Res.string.sort_by, + flag = 0, + order = true + ), + SortOrderOption( + fieldMessageId = Res.string.language, + flag = 1, + order = true + ) + ) + } + + private suspend fun processAppForDownloadedLessons( + app: world.respect.datalayer.school.model.SchoolApp, + downloadedLessons: MutableList, + seenManifestUrls: MutableSet, + lock: Any + ) { + val appManifest = appDataSource.compatibleAppsDataSource.getAppAsFlow( + manifestUrl = app.appManifestUrl, + loadParams = DataLoadParams() + ).first().dataOrNull() ?: return - _uiState.update { - it.copy( - navigation = resolvedFeed.navigation ?: emptyList(), - publications = resolvedFeed.publications?: emptyList(), - group = resolvedFeed.groups?: emptyList(), - facetOptions = facetOptions, - sortOptions = sortOptions - ) + val learningUnitsUrl = app.appManifestUrl.resolve( + appManifest.learningUnits.toString() + ) + + val mainFeed = appDataSource.opdsDataSource.loadOpdsFeed( + url = learningUnitsUrl, + params = DataLoadParams() + ).first().dataOrNull() ?: return + + val resolvedFeed = mainFeed.resolve(learningUnitsUrl) + + resolvedFeed.navigation?.map { navLink -> + viewModelScope.async { + try { + processNavigationLinkForDownload( + navLink = navLink, + baseUrl = learningUnitsUrl, + appManifestUrl = app.appManifestUrl, + downloadedLessons = downloadedLessons, + seenManifestUrls = seenManifestUrls, + lock = lock + ) + } catch (e: Exception) { + e.printStackTrace() + } + } + }?.awaitAll() + + resolvedFeed.publications?.forEach { pub -> + processPublicationForDownload( + publication = pub, + baseUrl = learningUnitsUrl, + appManifestUrl = app.appManifestUrl, + downloadedLessons = downloadedLessons, + seenManifestUrls = seenManifestUrls, + lock = lock + ) + } + } + + private suspend fun processNavigationLinkForDownload( + navLink: ReadiumLink, + baseUrl: Url, + appManifestUrl: Url, + downloadedLessons: MutableList, + seenManifestUrls: MutableSet, + lock: Any + ) { + val gradeFeedUrl = baseUrl.resolve(navLink.href) + val gradeFeed = appDataSource.opdsDataSource.loadOpdsFeed( + url = gradeFeedUrl, + params = DataLoadParams() + ).first().dataOrNull() ?: return + + val resolvedGradeFeed = gradeFeed.resolve(gradeFeedUrl) + + resolvedGradeFeed.publications?.forEach { pub -> + processPublicationForDownload( + publication = pub, + baseUrl = gradeFeedUrl, + appManifestUrl = appManifestUrl, + downloadedLessons = downloadedLessons, + seenManifestUrls = seenManifestUrls, + lock = lock + ) + } + } + + private suspend fun processPublicationForDownload( + publication: OpdsPublication, + baseUrl: Url, + appManifestUrl: Url, + downloadedLessons: MutableList, + seenManifestUrls: MutableSet, + lock: Any + ) { + val selfLink = publication.links.find { + it.rel?.contains(SELF) == true + } ?: return + + val manifestUrl = baseUrl.resolve(selfLink.href) + val manifestUrlStr = manifestUrl.toString() + + val isNew = synchronized(lock) { + seenManifestUrls.add(manifestUrlStr) + } + + if (!isNew) { + return + } + + val pinState = ustadCache.publicationPinState(manifestUrl).first() + + if (pinState.status == PublicationPinState.Status.READY) { + val selection = LearningUnitSelection( + learningUnitManifestUrl = manifestUrl, + selectedPublication = publication, + appManifestUrl = appManifestUrl + ) + + synchronized(lock) { + downloadedLessons.add(selection) + } + + _uiState.update { + it.copy(downloadedLessons = synchronized(lock) { + downloadedLessons.toList() + }) + } + } + } + + fun onClickDownloadedLesson(item: LearningUnitSelection) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + LearningUnitDetail.create( + learningUnitManifestUrl = item.learningUnitManifestUrl, + appManifestUrl = item.appManifestUrl, + expectedIdentifier = item.selectedPublication.metadata.identifier.toString() + ) + ) + ) + } + + fun onClickDeleteDownloaded(item: LearningUnitSelection) { + val originalIndex = _uiState.value.downloadedLessons.indexOfFirst { + it.learningUnitManifestUrl == item.learningUnitManifestUrl + } + + _uiState.update { + it.copy( + downloadedLessons = it.downloadedLessons.filter { d -> + d.learningUnitManifestUrl != item.learningUnitManifestUrl + } + ) + } + + viewModelScope.launch { + snackBarDispatcher.showSnackBar( + Snack( + message = Res.string.item_deleted.asUiText(), + action = "Undo", + onAction = { + _uiState.update { s -> + val mutableList = s.downloadedLessons.toMutableList() + val insertIndex = originalIndex.coerceAtMost(mutableList.size) + mutableList.add(insertIndex, item) + s.copy(downloadedLessons = mutableList) } } + ) + ) + + delay(UNDO_TIMEOUT_MS) - else -> {} + if (_uiState.value.downloadedLessons.none { + it.learningUnitManifestUrl == item.learningUnitManifestUrl + }) { + try { + ustadCache.unpinPublication(item.learningUnitManifestUrl) + } catch (e: Exception) { + e.printStackTrace() } } } @@ -137,9 +440,7 @@ class LearningUnitListViewModel( LearningUnitDetail.create( learningUnitManifestUrl = learningUnitManifestUrl, appManifestUrl = route.appManifestUrl, - refererUrl = Url( - refererUrl - ), + refererUrl = Url(refererUrl), expectedIdentifier = publication.metadata.identifier.toString() ) ) @@ -148,15 +449,12 @@ class LearningUnitListViewModel( } fun onClickNavigation(navigation: ReadiumLink) { - val navigationHref = navigation.href _navCommandFlow.tryEmit( NavCommand.Navigate( LearningUnitList.create( - opdsFeedUrl = route.opdsFeedUrl.resolve( - navigationHref - ), + opdsFeedUrl = route.opdsFeedUrl.resolve(navigationHref), appManifestUrl = route.appManifestUrl, resultDest = route.resultDest, ) @@ -167,7 +465,6 @@ class LearningUnitListViewModel( companion object { const val SELF = "self" const val ICON = "icon" - - + const val UNDO_TIMEOUT_MS = 5000L } -} +} \ No newline at end of file From 9664757a10e45da4030a7f8943feb2547ee30d85 Mon Sep 17 00:00:00 2001 From: lipsa-b Date: Mon, 2 Feb 2026 10:56:42 +0530 Subject: [PATCH 4/6] localize the string --- .../world/respect/shared/viewmodel/app/appstate/Snack.kt | 2 +- .../viewmodel/learningunit/list/LearningUnitListViewModel.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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/learningunit/list/LearningUnitListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt index b9f8cef18..330e9bc0e 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt @@ -34,6 +34,7 @@ import world.respect.shared.generated.resources.downloaded_lessons import world.respect.shared.generated.resources.item_deleted import world.respect.shared.generated.resources.language import world.respect.shared.generated.resources.sort_by +import world.respect.shared.generated.resources.undo import world.respect.shared.navigation.LearningUnitDetail import world.respect.shared.navigation.LearningUnitList import world.respect.shared.navigation.NavCommand @@ -384,7 +385,7 @@ class LearningUnitListViewModel( snackBarDispatcher.showSnackBar( Snack( message = Res.string.item_deleted.asUiText(), - action = "Undo", + action = Res.string.undo.asUiText(), onAction = { _uiState.update { s -> val mutableList = s.downloadedLessons.toMutableList() From 081780857c440a2a9a0ec5988040c2e65112ff76 Mon Sep 17 00:00:00 2001 From: lipsa-b Date: Mon, 2 Feb 2026 11:14:16 +0530 Subject: [PATCH 5/6] localize the string --- .../src/commonMain/kotlin/world/respect/app/app/App.kt | 9 ++++++++- .../world/respect/shared/viewmodel/app/appstate/Snack.kt | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) 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 c5db08d78..9f660485a 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 @@ -165,10 +165,17 @@ fun App( is StringResourceUiText -> getString(uiText.resource) else -> "" } + val actionLabel = snack.action?.let { actionUiText -> + when (actionUiText) { + is StringUiText -> actionUiText.text + is StringResourceUiText -> getString(actionUiText.resource) + else -> null + } + } val result = snackbarHostState.showSnackbar( message = message, - actionLabel = snack.action, + actionLabel = actionLabel, duration = SnackbarDuration.Short ) 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 fca02065a..fe125e9f3 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: UiText? = null, + val action: UiText?= null, val onAction: (() -> Unit)? = null, ) From 99fcae386afe2dfa9d16aee8fa0bd98e8758ed0a Mon Sep 17 00:00:00 2001 From: pooja Date: Tue, 3 Feb 2026 17:20:21 +0400 Subject: [PATCH 6/6] updated offline download --- .maestro/flows/002_browse_lessons_test.yaml | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.maestro/flows/002_browse_lessons_test.yaml b/.maestro/flows/002_browse_lessons_test.yaml index c939f86e0..fbc45f481 100644 --- a/.maestro/flows/002_browse_lessons_test.yaml +++ b/.maestro/flows/002_browse_lessons_test.yaml @@ -48,5 +48,47 @@ onFlowComplete: timeout: 1000 - assertVisible: "Hello World Lesson" - tapOn: "Close" +- tapOn: "Download" +- extendedWaitUntil: + visible: "Cancel" + timeout: 1000 +- extendedWaitUntil: + visible: "Downloaded" + timeout: 1000 +- tapOn: "My app" +- assertVisible: "Lessons" +- tapOn: "Lessons" +- tapOn: "Grade 2" +- tapOn: "Lesson 002" +- assertVisible: "Lesson 002" +- tapOn: "Download" +- extendedWaitUntil: + visible: "Cancel" + timeout: 1000 +- extendedWaitUntil: + visible: "Downloaded" + timeout: 1000 +- setAirplaneMode: enabled +- tapOn: "Apps" +- tapOn: + id: "downloaded_icon" +- assertVisible: + id: "app_title" + text: "Downloaded lessons" +- assertVisible: "Sort by" +- assertVisible: "Lesson 001" +- assertVisible: "Lesson 002" +- tapOn: "Lesson 002" +- assertVisible: "Open" +- assertVisible: "Downloaded" +- tapOn: "Open" +- assertVisible: "content visible" +- tapOn: "Close" +- back +- tapOn: + id: "delete_icon" +- assertVisible: "1 deleted" +- tapOn: "Undo" +- assertVisible: "Lesson 001"