diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/notebook/NotebookScreenUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/notebook/NotebookScreenUiTest.kt new file mode 100644 index 0000000000..8f7a97a0e3 --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/notebook/NotebookScreenUiTest.kt @@ -0,0 +1,339 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.ui.features.notebook + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteObjectType +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.horizon.R +import com.instructure.horizon.features.notebook.NotebookScreen +import com.instructure.horizon.features.notebook.NotebookUiState +import com.instructure.horizon.features.notebook.common.model.Note +import com.instructure.horizon.features.notebook.common.model.NotebookType +import com.instructure.horizon.horizonui.platform.LoadingState +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Date + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class NotebookScreenUiTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun testEmptyStateDisplaysWhenNoNotes() { + val state = createEmptyState() + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(context.getString(R.string.notesEmptyContentTitle)) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysHighlightedText() { + val highlightedText = "This is important highlighted text from the course material" + val state = createStateWithNotes( + notes = listOf(createTestNote(highlightedText = highlightedText)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(highlightedText, substring = true) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysUserComment() { + val userComment = "My personal note about this concept" + val state = createStateWithNotes( + notes = listOf(createTestNote(userText = userComment)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(userComment, substring = true) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysDate() { + val state = createStateWithNotes( + notes = listOf(createTestNote()) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNode(hasText("Jan", substring = true)) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysTypeImportant() { + val state = createStateWithNotes( + notes = listOf(createTestNote(type = NotebookType.Important)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText("Important", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysTypeConfusing() { + val state = createStateWithNotes( + notes = listOf(createTestNote(type = NotebookType.Confusing)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText("Unclear", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testCourseFilterDisplayedWhenEnabled() { + val state = createStateWithNotes( + showCourseFilter = true, + courses = listOf(createTestCourse()) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(context.getString(R.string.notebookFilterCoursePlaceholder), useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testNoteTypeFilterDisplayed() { + val state = createStateWithNotes( + showNoteTypeFilter = true, + notes = listOf(createTestNote()) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText("All notes", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testCourseNameDisplayedWhenCourseFilterVisible() { + val courseName = "Biology 101" + val state = createStateWithNotes( + showCourseFilter = true, + courses = listOf(createTestCourse(name = courseName, id = 123L)), + notes = listOf(createTestNote(courseId = 123L)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(courseName, substring = true) + .assertIsDisplayed() + } + + @Test + fun testCourseNameNotDisplayedWhenCourseFilterHidden() { + val courseName = "Biology 101" + val state = createStateWithNotes( + showCourseFilter = false, + courses = listOf(createTestCourse(name = courseName, id = 123L)), + notes = listOf(createTestNote(courseId = 123L)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNode(hasText(courseName)) + .assertDoesNotExist() + } + + @Test + fun testEmptyFilteredStateDisplayedWhenFilterApplied() { + val state = createEmptyState(selectedFilter = NotebookType.Important) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(context.getString(R.string.notesEmptyFilteredContentTitle)) + .assertIsDisplayed() + } + + @Test + fun testMultipleNotesDisplayed() { + val note1 = createTestNote( + id = "1", + highlightedText = "First important concept" + ) + val note2 = createTestNote( + id = "2", + highlightedText = "Second important concept" + ) + val state = createStateWithNotes(notes = listOf(note1, note2)) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText("First important concept", substring = true) + .assertIsDisplayed() + composeTestRule.onNodeWithText("Second important concept", substring = true) + .assertIsDisplayed() + } + + @Test + fun testShowMoreButtonDisplayedWhenHasNextPage() { + val state = createStateWithNotes( + notes = listOf(createTestNote()), + hasNextPage = true + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(context.getString(R.string.showMore)) + .assertIsDisplayed() + } + + private fun createEmptyState( + selectedFilter: NotebookType? = null + ): NotebookUiState { + return NotebookUiState( + loadingState = LoadingState(isLoading = false), + notes = emptyList(), + selectedFilter = selectedFilter, + showCourseFilter = true, + showNoteTypeFilter = true + ) + } + + private fun createStateWithNotes( + notes: List = emptyList(), + showCourseFilter: Boolean = false, + showNoteTypeFilter: Boolean = false, + courses: List = emptyList(), + hasNextPage: Boolean = false, + selectedFilter: NotebookType? = null + ): NotebookUiState { + return NotebookUiState( + loadingState = LoadingState(isLoading = false), + notes = notes, + courses = courses, + showCourseFilter = showCourseFilter, + showNoteTypeFilter = showNoteTypeFilter, + hasNextPage = hasNextPage, + selectedFilter = selectedFilter + ) + } + + private fun createTestNote( + id: String = "note1", + highlightedText: String = "Test highlighted text from course material", + userText: String = "My personal annotation", + type: NotebookType = NotebookType.Important, + courseId: Long = 123L + ): Note { + return Note( + id = id, + highlightedText = NoteHighlightedData( + selectedText = highlightedText, + range = NoteHighlightedDataRange(0, highlightedText.length, "", ""), + textPosition = NoteHighlightedDataTextPosition(0, highlightedText.length) + ), + type = type, + userText = userText, + updatedAt = Date(1706140800000L), + courseId = courseId, + objectType = NoteObjectType.Assignment, + objectId = "assignment123" + ) + } + + private fun createTestCourse( + name: String = "Test Course", + id: Long = 123L + ): CourseWithProgress { + return CourseWithProgress( + courseId = id, + courseName = name, + progress = 0.0 + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/account/navigation/AccountNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/account/navigation/AccountNavigation.kt index 02987a6c15..1c6930943f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/account/navigation/AccountNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/account/navigation/AccountNavigation.kt @@ -16,11 +16,9 @@ */ package com.instructure.horizon.features.account.navigation -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable @@ -38,10 +36,9 @@ import com.instructure.horizon.features.account.profile.AccountProfileScreen import com.instructure.horizon.features.account.profile.AccountProfileViewModel import com.instructure.horizon.features.account.reportabug.ReportABugWebView import com.instructure.horizon.features.home.HomeNavigationRoute +import com.instructure.horizon.horizonui.animation.NavigationTransitionAnimation import com.instructure.horizon.horizonui.animation.enterTransition import com.instructure.horizon.horizonui.animation.exitTransition -import com.instructure.horizon.horizonui.animation.mainEnterTransition -import com.instructure.horizon.horizonui.animation.mainExitTransition import com.instructure.horizon.horizonui.animation.popEnterTransition import com.instructure.horizon.horizonui.animation.popExitTransition @@ -51,10 +48,10 @@ fun NavGraphBuilder.accountNavigation( navigation( route = HomeNavigationRoute.Account.route, startDestination = AccountRoute.Account.route, - enterTransition = { if (isBottomNavDestination()) mainEnterTransition else enterTransition }, - exitTransition = { if (isBottomNavDestination()) mainExitTransition else exitTransition }, - popEnterTransition = { if (isBottomNavDestination()) mainEnterTransition else popEnterTransition }, - popExitTransition = { if (isBottomNavDestination()) mainExitTransition else popExitTransition }, + enterTransition = { enterTransition(NavigationTransitionAnimation.SCALE) }, + exitTransition = { exitTransition(NavigationTransitionAnimation.SCALE) }, + popEnterTransition = { popEnterTransition(NavigationTransitionAnimation.SCALE) }, + popExitTransition = { popExitTransition(NavigationTransitionAnimation.SCALE) }, ) { composable( route = AccountRoute.Account.route, @@ -96,15 +93,4 @@ fun NavGraphBuilder.accountNavigation( ReportABugWebView(navController) } } -} - -private fun AnimatedContentTransitionScope.isBottomNavDestination(): Boolean { - val sourceRoute = this.initialState.destination.route ?: return false - val destinationRoute = this.targetState.destination.route ?: return false - return sourceRoute.contains(HomeNavigationRoute.Learn.route) - || sourceRoute.contains(HomeNavigationRoute.Dashboard.route) - || sourceRoute.contains(HomeNavigationRoute.Skillspace.route) - || destinationRoute.contains(HomeNavigationRoute.Learn.route) - || destinationRoute.contains(HomeNavigationRoute.Dashboard.route) - || destinationRoute.contains(HomeNavigationRoute.Skillspace.route) } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeNavigation.kt index 8d383b4193..39b6f9a5df 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeNavigation.kt @@ -34,8 +34,11 @@ import com.instructure.horizon.features.learn.LearnScreen import com.instructure.horizon.features.learn.LearnViewModel import com.instructure.horizon.features.skillspace.SkillspaceScreen import com.instructure.horizon.features.skillspace.SkillspaceViewModel -import com.instructure.horizon.horizonui.animation.mainEnterTransition -import com.instructure.horizon.horizonui.animation.mainExitTransition +import com.instructure.horizon.horizonui.animation.NavigationTransitionAnimation +import com.instructure.horizon.horizonui.animation.enterTransition +import com.instructure.horizon.horizonui.animation.exitTransition +import com.instructure.horizon.horizonui.animation.popEnterTransition +import com.instructure.horizon.horizonui.animation.popExitTransition import com.instructure.horizon.horizonui.showroom.ShowroomContent import com.instructure.horizon.horizonui.showroom.ShowroomItem import com.instructure.horizon.horizonui.showroom.showroomItems @@ -64,10 +67,10 @@ sealed class HomeNavigationRoute(val route: String) { fun HomeNavigation(navController: NavHostController, mainNavController: NavHostController, modifier: Modifier = Modifier) { NavHost( navController, - enterTransition = { mainEnterTransition }, - exitTransition = { mainExitTransition }, - popEnterTransition = { mainEnterTransition }, - popExitTransition = { mainExitTransition }, + enterTransition = { enterTransition(NavigationTransitionAnimation.SCALE) }, + exitTransition = { exitTransition(NavigationTransitionAnimation.SCALE) }, + popEnterTransition = { popEnterTransition(NavigationTransitionAnimation.SCALE) }, + popExitTransition = { popExitTransition(NavigationTransitionAnimation.SCALE) }, startDestination = HomeNavigationRoute.Dashboard.route, modifier = modifier ) { composable(HomeNavigationRoute.Dashboard.route) { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/navigation/HorizonInboxNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/navigation/HorizonInboxNavigation.kt index 66ffe49d1d..63d2f67352 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/navigation/HorizonInboxNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/navigation/HorizonInboxNavigation.kt @@ -16,12 +16,10 @@ */ package com.instructure.horizon.features.inbox.navigation -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable @@ -39,12 +37,9 @@ import com.instructure.horizon.features.inbox.list.HorizonInboxListScreen import com.instructure.horizon.features.inbox.list.HorizonInboxListViewModel import com.instructure.horizon.horizonui.animation.enterTransition import com.instructure.horizon.horizonui.animation.exitTransition -import com.instructure.horizon.horizonui.animation.mainEnterTransition -import com.instructure.horizon.horizonui.animation.mainExitTransition import com.instructure.horizon.horizonui.animation.popEnterTransition import com.instructure.horizon.horizonui.animation.popExitTransition import com.instructure.horizon.navigation.MainNavigationRoute -import com.instructure.pandautils.utils.orDefault fun NavGraphBuilder.horizonInboxNavigation( navController: NavHostController, @@ -55,10 +50,10 @@ fun NavGraphBuilder.horizonInboxNavigation( ) { composable( HorizonInboxRoute.InboxList.route, - enterTransition = { if (isComposeTransition()) mainEnterTransition else enterTransition }, - exitTransition = { if (isComposeTransition()) mainExitTransition else exitTransition }, - popEnterTransition = { if (isComposeTransition()) mainEnterTransition else popEnterTransition }, - popExitTransition = { if (isComposeTransition()) mainExitTransition else popExitTransition }, + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() }, ) { val viewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsState() @@ -66,10 +61,6 @@ fun NavGraphBuilder.horizonInboxNavigation( } composable( HorizonInboxRoute.InboxDetails.route, - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, arguments = listOf( navArgument(HorizonInboxRoute.InboxDetails.TYPE) { type = androidx.navigation.NavType.StringType @@ -90,10 +81,6 @@ fun NavGraphBuilder.horizonInboxNavigation( } composable( HorizonInboxRoute.InboxCompose.route, - enterTransition = { mainEnterTransition }, - exitTransition = { mainExitTransition }, - popEnterTransition = { mainEnterTransition }, - popExitTransition = { mainExitTransition }, ) { val viewModel: HorizonInboxComposeViewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsState() @@ -107,10 +94,6 @@ fun NavGraphBuilder.horizonInboxNavigation( // Conversation Details from deeplink composable( HorizonInboxRoute.InboxDetailsDeepLink.route, - enterTransition = { mainEnterTransition }, - exitTransition = { mainExitTransition }, - popEnterTransition = { mainEnterTransition }, - popExitTransition = { mainExitTransition }, arguments = listOf( navArgument(HorizonInboxRoute.InboxDetails.ID) { type = androidx.navigation.NavType.LongType @@ -135,10 +118,6 @@ fun NavGraphBuilder.horizonInboxNavigation( // Announcement Details from deeplink composable( HorizonInboxRoute.CourseAnnouncementDetailsDeepLink.route, - enterTransition = { mainEnterTransition }, - exitTransition = { mainExitTransition }, - popEnterTransition = { mainEnterTransition }, - popExitTransition = { mainExitTransition }, arguments = listOf( navArgument(HorizonInboxRoute.InboxDetails.ID) { type = androidx.navigation.NavType.LongType @@ -167,13 +146,4 @@ fun NavGraphBuilder.horizonInboxNavigation( } } } -} - -private fun AnimatedContentTransitionScope.isComposeTransition(): Boolean { - return this.targetState.destination.route - ?.startsWith(HorizonInboxRoute.InboxCompose.route) - .orDefault() || - this.initialState.destination.route - ?.startsWith(HorizonInboxRoute.InboxCompose.route) - .orDefault() } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookRepository.kt index 88cb454dac..d3240e99ce 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookRepository.kt @@ -94,4 +94,8 @@ class NotebookRepository @Inject constructor( forceNetwork = forceNetwork ).dataOrNull.orEmpty() } + + suspend fun deleteNote(noteId: String) { + redwoodApiManager.deleteNote(noteId) + } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookScreen.kt index 5b4f26ba1a..4e03a3aafa 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookScreen.kt @@ -14,8 +14,6 @@ * along with this program. If not, see . * */ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) - package com.instructure.horizon.features.notebook import androidx.compose.foundation.background @@ -32,6 +30,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -42,7 +41,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -51,31 +53,34 @@ import androidx.navigation.NavHostController import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R +import com.instructure.horizon.features.notebook.common.composable.NoteDeleteConfirmationDialog import com.instructure.horizon.features.notebook.common.composable.NotebookAppBar import com.instructure.horizon.features.notebook.common.composable.NotebookHighlightedText -import com.instructure.horizon.features.notebook.common.composable.NotebookPill import com.instructure.horizon.features.notebook.common.composable.NotebookTypeSelect import com.instructure.horizon.features.notebook.common.composable.toNotebookLocalisedDateFormat import com.instructure.horizon.features.notebook.common.model.Note import com.instructure.horizon.features.notebook.navigation.NotebookRoute import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius -import com.instructure.horizon.horizonui.foundation.HorizonElevation import com.instructure.horizon.horizonui.foundation.HorizonSpace import com.instructure.horizon.horizonui.foundation.HorizonTypography import com.instructure.horizon.horizonui.foundation.SpaceSize -import com.instructure.horizon.horizonui.foundation.horizonShadow +import com.instructure.horizon.horizonui.foundation.horizonBorder +import com.instructure.horizon.horizonui.foundation.horizonBorderShadow import com.instructure.horizon.horizonui.molecules.Button import com.instructure.horizon.horizonui.molecules.ButtonColor import com.instructure.horizon.horizonui.molecules.ButtonHeight import com.instructure.horizon.horizonui.molecules.ButtonWidth import com.instructure.horizon.horizonui.molecules.DropdownChip import com.instructure.horizon.horizonui.molecules.DropdownItem -import com.instructure.horizon.horizonui.molecules.HorizonDivider +import com.instructure.horizon.horizonui.molecules.IconButtonColor +import com.instructure.horizon.horizonui.molecules.IconButtonSize +import com.instructure.horizon.horizonui.molecules.LoadingIconButton import com.instructure.horizon.horizonui.molecules.Spinner import com.instructure.horizon.horizonui.organisms.CollapsableHeaderScreen import com.instructure.horizon.horizonui.platform.LoadingStateWrapper import com.instructure.horizon.navigation.MainNavigationRoute +import com.instructure.pandautils.compose.modifiers.conditional import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.getActivityOrNull import com.instructure.pandautils.utils.localisedFormat @@ -86,6 +91,19 @@ fun NotebookScreen( viewModel: NotebookViewModel, ) { val state by viewModel.uiState.collectAsState() + + NotebookScreen( + navController = mainNavController, + state = state + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotebookScreen( + navController: NavHostController, + state: NotebookUiState +) { val activity = LocalContext.current.getActivityOrNull() LaunchedEffect(Unit) { if (activity != null) ViewStyler.setStatusBarColor( @@ -96,14 +114,30 @@ fun NotebookScreen( val scrollState = rememberLazyListState() + NoteDeleteConfirmationDialog( + showDialog = state.showDeleteConfirmationForNote != null, + dismissDialog = { state.updateShowDeleteConfirmation(null) }, + onDeleteSelected = { + state.deleteNote(state.showDeleteConfirmationForNote) + } + ) + CollapsableHeaderScreen( modifier = Modifier.background(HorizonColors.Surface.pagePrimary()), headerContent = { if (state.showTopBar) { - NotebookAppBar( - navigateBack = { mainNavController.popBackStack() }, - centeredTitle = true - ) + if (state.showCourseFilter) { + NotebookAppBar( + navigateBack = { navController.popBackStack() }, + centeredTitle = true + ) + } else { + NotebookAppBar( + onClose = { navController.popBackStack() }, + centeredTitle = false + ) + } + } }, bodyContent = { @@ -113,17 +147,25 @@ fun NotebookScreen( modifier = Modifier .fillMaxSize() ) { - if ((state.showNoteTypeFilter || state.showCourseFilter) && (state.courses.isNotEmpty() || state.selectedCourse != null || state.selectedFilter != null)) { + if ((state.showNoteTypeFilter || state.showCourseFilter)) { FilterContent( state, scrollState, Modifier + .background(HorizonColors.Surface.pagePrimary()) .clip(HorizonCornerRadius.level5) - .background(HorizonColors.Surface.pageSecondary()), + .conditional(scrollState.canScrollBackward) { + horizonBorderShadow( + HorizonColors.Surface.inversePrimary(), + bottom = 1.dp, + ) + } + .background(HorizonColors.Surface.pageSecondary()) ) } LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), state = scrollState, modifier = Modifier .fillMaxHeight() @@ -146,9 +188,14 @@ fun NotebookScreen( } else { items(state.notes) { note -> Column { - NoteContent(note) { + val courseName = if (state.showCourseFilter) { + state.courses.firstOrNull { it.courseId == note.courseId }?.courseName + } else null + NoteContent(note, courseName, state.deleteLoadingNote, onDeleteClick = { + state.updateShowDeleteConfirmation(note) + }) { if (state.navigateToEdit) { - mainNavController.navigate( + navController.navigate( NotebookRoute.EditNotebook( noteId = note.id, highlightedTextStartOffset = note.highlightedText.range.startOffset, @@ -164,7 +211,7 @@ fun NotebookScreen( ) ) } else { - mainNavController.navigate( + navController.navigate( MainNavigationRoute.ModuleItemSequence( courseId = note.courseId, moduleItemAssetType = note.objectType.value, @@ -191,7 +238,6 @@ fun NotebookScreen( width = ButtonWidth.FILL, color = ButtonColor.WhiteWithOutline, onClick = { state.loadNextPage() }, - modifier = Modifier.padding(vertical = 24.dp) ) } } @@ -238,32 +284,26 @@ private fun FilterContent( if (state.selectedCourse == null) allCoursesItem else courseItems.find { it.value == state.selectedCourse } - Column { - Row( - modifier = modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (state.showCourseFilter) { - DropdownChip( - items = courseItems, - selectedItem = selectedCourseItem, - onItemSelected = { item -> state.onCourseSelected(item?.value) }, - placeholder = stringResource(R.string.notebookFilterCoursePlaceholder), - dropdownWidth = 178.dp, - verticalPadding = 6.dp, - modifier = Modifier.weight(1f, false) - ) - } - - if (state.showNoteTypeFilter) { - NotebookTypeSelect(state.selectedFilter, state.onFilterSelected, false, true) - } + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (state.showCourseFilter) { + DropdownChip( + items = courseItems, + selectedItem = selectedCourseItem, + onItemSelected = { item -> state.onCourseSelected(item?.value) }, + placeholder = stringResource(R.string.notebookFilterCoursePlaceholder), + dropdownWidth = 178.dp, + verticalPadding = 6.dp, + modifier = Modifier.weight(1f, false) + ) } - if (scrollState.canScrollBackward) { - HorizonDivider() + if (state.showNoteTypeFilter) { + NotebookTypeSelect(state.selectedFilter, state.onFilterSelected, false, true) } } } @@ -284,20 +324,27 @@ private fun LoadingContent() { @Composable private fun NoteContent( note: Note, + courseName: String?, + deleteLoading: Note?, + onDeleteClick: () -> Unit, onClick: () -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() - .horizonShadow( - elevation = HorizonElevation.level4, - shape = HorizonCornerRadius.level2, - clip = true + .horizonBorder( + colorResource(note.type.color).copy(alpha = 0.1f), + 6.dp, + 1.dp, + 1.dp, + 6.dp, + 16.dp ) .background( color = HorizonColors.PrimitivesWhite.white10(), shape = HorizonCornerRadius.level2, ) + .clip(HorizonCornerRadius.level2) .clickable { onClick() } ) { Column( @@ -305,10 +352,17 @@ private fun NoteContent( .fillMaxWidth() .padding(24.dp) ) { - Text( - text = note.updatedAt.localisedFormat("MMM d, yyyy"), - style = HorizonTypography.labelSmall, - color = HorizonColors.Text.timestamp() + val typeName = stringResource(note.type.labelRes) + NotebookTypeSelect( + note.type, + verticalPadding = 2.dp, + onSelect = {}, + showIcons = true, + enabled = false, + showAllOption = false, + modifier = Modifier.clearAndSetSemantics { + contentDescription = typeName + } ) HorizonSpace(SpaceSize.SPACE_16) @@ -330,10 +384,44 @@ private fun NoteContent( overflow = TextOverflow.Ellipsis, ) - HorizonSpace(SpaceSize.SPACE_16) + HorizonSpace(SpaceSize.SPACE_8) } - NotebookPill(note.type) + Row { + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ){ + Text( + text = note.updatedAt.localisedFormat("MMM d, yyyy"), + style = HorizonTypography.labelMediumBold, + color = HorizonColors.Text.timestamp(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (courseName != null) { + Text( + text = courseName, + style = HorizonTypography.labelMediumBold, + color = HorizonColors.Text.timestamp(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + LoadingIconButton( + iconRes = R.drawable.delete, + contentDescription = stringResource(R.string.a11y_notebookDeleteNoteButtonContentDescription), + color = IconButtonColor.InverseDanger, + size = IconButtonSize.SMALL, + onClick = { onDeleteClick() }, + loading = note == deleteLoading, + modifier = Modifier.align(Alignment.Bottom) + ) + } } } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookUiState.kt index af097acf76..901b289d91 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookUiState.kt @@ -37,4 +37,8 @@ data class NotebookUiState( val navigateToEdit: Boolean = false, val showNoteTypeFilter: Boolean = true, val showCourseFilter: Boolean = true, + val showDeleteConfirmationForNote: Note? = null, + val updateShowDeleteConfirmation: (Note?) -> Unit = {}, + val deleteNote: (Note?) -> Unit = {}, + val deleteLoadingNote: Note? = null ) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookViewModel.kt index e52ca48adb..f36d491956 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookViewModel.kt @@ -71,6 +71,8 @@ class NotebookViewModel @Inject constructor( showFilters = showFilters, showCourseFilter = courseId == null, navigateToEdit = navigateToEdit, + updateShowDeleteConfirmation = ::updateShowDeleteConfirmation, + deleteNote = ::deleteNote ) ) val uiState = _uiState.asStateFlow() @@ -196,4 +198,20 @@ class NotebookViewModel @Inject constructor( val objectId = savedStateHandle.get(NotebookRoute.Notebook.OBJECT_ID) ?: return null return Pair(objectType, objectId) } + + private fun updateShowDeleteConfirmation(note: Note?) { + _uiState.update { it.copy(showDeleteConfirmationForNote = note) } + } + + private fun deleteNote(note: Note?) { + if (note != null) { + viewModelScope.tryLaunch { + _uiState.update { it.copy(deleteLoadingNote = note) } + repository.deleteNote(note.id) + _uiState.update { it.copy(deleteLoadingNote = null, notes = it.notes.filterNot { it == note }) } + } catch { + _uiState.update { it.copy(deleteLoadingNote = null) } + } + } + } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteScreen.kt index 9675d870f5..e2563e208e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteScreen.kt @@ -48,6 +48,7 @@ import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlight import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R +import com.instructure.horizon.features.notebook.common.composable.NoteDeleteConfirmationDialog import com.instructure.horizon.features.notebook.common.composable.NotebookHighlightedText import com.instructure.horizon.features.notebook.common.composable.NotebookTypeSelect import com.instructure.horizon.features.notebook.common.model.NotebookType @@ -99,7 +100,15 @@ fun AddEditNoteScreen( if (state.isLoading) { AddEditNoteLoading(padding) } else { - AddEditNoteScreenDeleteConfirmationDialog(state, navController) + NoteDeleteConfirmationDialog( + showDialog = state.showDeleteConfirmationDialog, + onDeleteSelected = { + state.onDeleteNote?.invoke { + navController.popBackStack() + } + }, + dismissDialog = { state.updateDeleteConfirmationDialog(false) } + ) AddEditNoteScreenExitConfirmationDialog(state, navController) AddEditNoteContent(state, padding) } @@ -233,30 +242,6 @@ private fun AddEditNoteLoading(padding: PaddingValues) { } } -@Composable -private fun AddEditNoteScreenDeleteConfirmationDialog( - state: AddEditNoteUiState, - navController: NavHostController -) { - if (state.showDeleteConfirmationDialog) { - Modal( - ModalDialogState( - title = stringResource(R.string.deleteNoteConfirmationTitle), - message = stringResource(R.string.deleteNoteConfirmationMessage), - primaryButtonTitle = stringResource(R.string.deleteNoteConfirmationDeleteLabel), - primaryButtonClick = { - state.updateDeleteConfirmationDialog(false) - state.onDeleteNote?.invoke { navController.popBackStack() } - }, - secondaryButtonTitle = stringResource(R.string.deleteNoteConfirmationCancelLabel), - secondaryButtonClick = { state.updateDeleteConfirmationDialog(false) } - - ), - onDismiss = { state.updateDeleteConfirmationDialog(false) } - ) - } -} - @Composable private fun AddEditNoteScreenExitConfirmationDialog( state: AddEditNoteUiState, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/AddEditNoteScreenDeleteConfirmationDialog.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/AddEditNoteScreenDeleteConfirmationDialog.kt new file mode 100644 index 0000000000..97bbccc2a0 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/AddEditNoteScreenDeleteConfirmationDialog.kt @@ -0,0 +1,32 @@ +package com.instructure.horizon.features.notebook.common.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.instructure.horizon.R +import com.instructure.horizon.horizonui.organisms.Modal +import com.instructure.horizon.horizonui.organisms.ModalDialogState + +@Composable +fun NoteDeleteConfirmationDialog( + showDialog: Boolean, + onDeleteSelected: () -> Unit, + dismissDialog: () -> Unit, +) { + if (showDialog) { + Modal( + ModalDialogState( + title = stringResource(R.string.deleteNoteConfirmationTitle), + message = stringResource(R.string.deleteNoteConfirmationMessage), + primaryButtonTitle = stringResource(R.string.deleteNoteConfirmationDeleteLabel), + primaryButtonClick = { + dismissDialog() + onDeleteSelected() + }, + secondaryButtonTitle = stringResource(R.string.deleteNoteConfirmationCancelLabel), + secondaryButtonClick = { dismissDialog() } + + ), + onDismiss = { dismissDialog() } + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookPill.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookPill.kt deleted file mode 100644 index da6c3daa2b..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookPill.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.notebook.common.composable - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import com.instructure.canvasapi2.utils.ContextKeeper -import com.instructure.horizon.features.notebook.common.model.NotebookType -import com.instructure.horizon.horizonui.molecules.Pill -import com.instructure.horizon.horizonui.molecules.PillCase -import com.instructure.horizon.horizonui.molecules.PillSize -import com.instructure.horizon.horizonui.molecules.PillStyle -import com.instructure.horizon.horizonui.molecules.PillType - -@Composable -fun NotebookPill( - type: NotebookType, - modifier: Modifier = Modifier -) { - val pillType = when (type) { - NotebookType.Confusing -> PillType.DANGER - NotebookType.Important -> PillType.INSTITUTION - } - - Pill( - label = stringResource(type.labelRes), - style = PillStyle.OUTLINE, - type = pillType, - case = PillCase.UPPERCASE, - size = PillSize.REGULAR, - iconRes = type.iconRes, - modifier = modifier - ) -} - -@Composable -@Preview -private fun NotebookPillConfusingPreview() { - ContextKeeper.appContext = LocalContext.current - NotebookPill(type = NotebookType.Confusing) -} - -@Composable -@Preview -private fun NotebookPillImportantPreview() { - ContextKeeper.appContext = LocalContext.current - NotebookPill(type = NotebookType.Important) -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookTypeSelect.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookTypeSelect.kt index 5fd78f56dc..12165e50b3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookTypeSelect.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookTypeSelect.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.instructure.horizon.R import com.instructure.horizon.features.notebook.common.model.NotebookType @@ -37,6 +38,8 @@ fun NotebookTypeSelect( showIcons: Boolean, showAllOption: Boolean, modifier: Modifier = Modifier, + enabled: Boolean = true, + verticalPadding: Dp = 6.dp ) { val context = LocalContext.current val defaultBackgroundColor = HorizonColors.PrimitivesGrey.grey12() @@ -84,8 +87,9 @@ fun NotebookTypeSelect( onItemSelected = { item -> onSelect(item?.value) }, placeholder = stringResource(R.string.notebookFilterTypePlaceholder), dropdownWidth = 178.dp, - verticalPadding = 6.dp, + verticalPadding = verticalPadding, showIconCollapsed = showIcons, + enabled = enabled, borderColor = if (showIcons) { selectedTypeItem?.iconTint ?: HorizonColors.LineAndBorder.lineStroke() } else { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookNavigation.kt index ef45e0b86d..70eefb369b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookNavigation.kt @@ -43,10 +43,10 @@ fun NavGraphBuilder.notebookNavigation( navigation( route = MainNavigationRoute.Notebook.route, startDestination = NotebookRoute.Notebook.route, - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() }, ) { composable( route = NotebookRoute.Notebook.route, @@ -91,4 +91,4 @@ fun NavGraphBuilder.notebookNavigation( AddEditNoteScreen(navController, uiState, onShowSnackbar) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/animation/NavigationTransation.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/animation/NavigationTransation.kt index ef92690f59..12413acdb3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/animation/NavigationTransation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/animation/NavigationTransation.kt @@ -16,30 +16,104 @@ */ package com.instructure.horizon.horizonui.animation +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import com.instructure.horizon.navigation.animationRules private const val animatedAlpha: Float = 0.0f private const val animatedScale: Float = 0.8f -val enterTransition = slideInHorizontally(initialOffsetX = { it }) + +private val slideEnterTransition = slideInHorizontally(initialOffsetX = { it }) + fadeIn(initialAlpha = animatedAlpha) -val exitTransition = slideOutHorizontally(targetOffsetX = { -it }) + +private val slideExitTransition = slideOutHorizontally(targetOffsetX = { -it }) + fadeOut(targetAlpha = animatedAlpha) -val popEnterTransition = slideInHorizontally(initialOffsetX = { -it }) + +private val slidePopEnterTransition = slideInHorizontally(initialOffsetX = { -it }) + fadeIn(initialAlpha = animatedAlpha) -val popExitTransition = slideOutHorizontally(targetOffsetX = { it/2 }) + +private val slidePopExitTransition = slideOutHorizontally(targetOffsetX = { it / 2 }) + fadeOut(targetAlpha = animatedAlpha) -val mainEnterTransition = fadeIn(initialAlpha = animatedAlpha) + +private val scaleEnterTransition = fadeIn(initialAlpha = animatedAlpha) + scaleIn(initialScale = animatedScale) -val mainExitTransition = fadeOut(targetAlpha = animatedAlpha) + - scaleOut(targetScale = animatedScale) \ No newline at end of file +private val scaleExitTransition = ExitTransition.None + +private val scalePopEnterTransition = EnterTransition.None + +private val scalePopExitTransition = fadeOut(targetAlpha = animatedAlpha) + + scaleOut(targetScale = animatedScale) + +enum class NavigationTransitionAnimation( + val enterTransition: EnterTransition, + val exitTransition: ExitTransition, + val popEnterTransition: EnterTransition, + val popExitTransition: ExitTransition +) { + SLIDE( + enterTransition = slideEnterTransition, + exitTransition = slideExitTransition, + popEnterTransition = slidePopEnterTransition, + popExitTransition = slidePopExitTransition + ), + SCALE( + enterTransition = scaleEnterTransition, + exitTransition = scaleExitTransition, + popEnterTransition = scalePopEnterTransition, + popExitTransition = scalePopExitTransition + ) +} + + +fun AnimatedContentTransitionScope.enterTransition( + defaultTransitionStyle: NavigationTransitionAnimation = NavigationTransitionAnimation.SLIDE +): EnterTransition { + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + + return (findAnimationStyle(fromRoute, toRoute) ?: defaultTransitionStyle).enterTransition +} + +fun AnimatedContentTransitionScope.exitTransition( + defaultTransitionStyle: NavigationTransitionAnimation = NavigationTransitionAnimation.SLIDE +): ExitTransition { + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + + return (findAnimationStyle(fromRoute, toRoute) ?: defaultTransitionStyle).exitTransition +} + +fun AnimatedContentTransitionScope.popEnterTransition( + defaultTransitionStyle: NavigationTransitionAnimation = NavigationTransitionAnimation.SLIDE +): EnterTransition { + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + + return (findAnimationStyle(toRoute, fromRoute) ?: defaultTransitionStyle).popEnterTransition +} + +fun AnimatedContentTransitionScope.popExitTransition( + defaultTransitionStyle: NavigationTransitionAnimation = NavigationTransitionAnimation.SLIDE +): ExitTransition { + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + + return (findAnimationStyle(toRoute, fromRoute) ?: defaultTransitionStyle).popExitTransition +} + +private fun findAnimationStyle(fromRoute: String?, toRoute: String?): NavigationTransitionAnimation? { + return animationRules.firstOrNull { rule -> + val fromMatches = rule.from == null || fromRoute?.startsWith(rule.from) == true + val toMatches = rule.to == null || toRoute?.startsWith(rule.to) == true + fromMatches && toMatches + }?.style +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonBorder.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonBorder.kt index e5504ee9bd..91816e8464 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonBorder.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonBorder.kt @@ -1,10 +1,142 @@ package com.instructure.horizon.horizonui.foundation import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp object HorizonBorder { fun level1(color: Color = HorizonColors.LineAndBorder.lineStroke()) = BorderStroke(1.dp, color) fun level2(color: Color = HorizonColors.LineAndBorder.lineStroke()) = BorderStroke(2.dp, color) -} \ No newline at end of file +} + +fun Modifier.horizonBorder( + color: Color, + start: Dp = 0.dp, + top: Dp = 0.dp, + end: Dp = 0.dp, + bottom: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return this + .padding(start, top, end, bottom) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width - start.roundToPx() - end.roundToPx() + val height = placeable.height - top.roundToPx() - bottom.roundToPx() + + layout(width, height) { + placeable.place(-start.roundToPx(), -top.roundToPx()) + } + } + .dropShadow(RoundedCornerShape(cornerRadius)) { + this.color = color + this.radius = 0f + } + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width + start.roundToPx() + end.roundToPx() + val height = placeable.height + top.roundToPx() + bottom.roundToPx() + + layout(width, height) { + placeable.place(start.roundToPx(), top.roundToPx()) + } + } +} + +fun Modifier.horizonBorderShadow( + color: Color, + start: Dp = 0.dp, + top: Dp = 0.dp, + end: Dp = 0.dp, + bottom: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + val maxShadow = maxOf(start, top, end, bottom) + + return this + .padding(start, top, end, bottom) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width - start.roundToPx() - end.roundToPx() + val height = placeable.height - top.roundToPx() - bottom.roundToPx() + + layout(width, height) { + placeable.place(-start.roundToPx(), -top.roundToPx()) + } + } + .dropShadow(RoundedCornerShape(cornerRadius)) { + this.color = color.copy(0.1f) + this.radius = maxShadow.toPx() + + val offsetX = if (start == 0.dp) { + this.radius = maxShadow.toPx() / 2 + radius + } else if (end == 0.dp) { + this.radius = maxShadow.toPx() / 2 + -radius + } else { + 0f + } + val offsetY = if (top == 0.dp) { + this.radius = maxShadow.toPx() / 2 + radius + } else if (bottom == 0.dp) { + this.radius = maxShadow.toPx() / 2 + -radius + } else { + 0f + } + this.offset = Offset(offsetX, offsetY) + } + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width + start.roundToPx() + end.roundToPx() + val height = placeable.height + top.roundToPx() + bottom.roundToPx() + + layout(width, height) { + placeable.place(start.roundToPx(), top.roundToPx()) + } + } +} + +fun Modifier.horizonBorder( + color: Color, + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return horizonBorder(color, horizontal, vertical, horizontal, vertical, cornerRadius) +} + +fun Modifier.horizonBorder( + color: Color, + all: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return horizonBorder(color, all, all, cornerRadius) +} + +fun Modifier.horizonBorderShadow( + color: Color, + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return horizonBorderShadow(color, horizontal, vertical, horizontal, vertical, cornerRadius) +} + +fun Modifier.horizonBorderShadow( + color: Color, + all: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return horizonBorderShadow(color, all, all, cornerRadius) +} + diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/DropdownChip.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/DropdownChip.kt index feae7094cf..4b75991219 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/DropdownChip.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/DropdownChip.kt @@ -76,6 +76,7 @@ fun DropdownChip( dropdownWidth: Dp? = null, placeholder: String, showIconCollapsed: Boolean = false, + enabled: Boolean = true, borderColor: Color = HorizonColors.LineAndBorder.lineStroke(), contentColor: Color = HorizonColors.Text.body(), verticalPadding: Dp = 0.dp @@ -108,7 +109,7 @@ fun DropdownChip( HorizonCornerRadius.level1 ) .clip(HorizonCornerRadius.level1) - .clickable { isMenuOpen = !isMenuOpen } + .conditional(enabled) { clickable { isMenuOpen = !isMenuOpen } } .padding(horizontal = 8.dp, vertical = 2.dp) .onGloballyPositioned { heightInPx = it.size.height @@ -141,37 +142,41 @@ fun DropdownChip( modifier = Modifier .weight(1f, false) .padding( - end = 2.dp, - top = verticalPadding, - bottom = verticalPadding - ) + end = 2.dp, + top = verticalPadding, + bottom = verticalPadding + ) ) - Icon( - painter = painterResource(R.drawable.keyboard_arrow_down), - contentDescription = null, - modifier = Modifier - .size(16.dp) - .rotate(iconRotation.value.toFloat()), - tint = contentColor - ) + if (enabled) { + Icon( + painter = painterResource(R.drawable.keyboard_arrow_down), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .rotate(iconRotation.value.toFloat()), + tint = contentColor + ) + } } - InputDropDownPopup( - isMenuOpen = isMenuOpen, - options = items, - width = width, - verticalOffsetPx = heightInPx, - onMenuOpenChanged = { isMenuOpen = it }, - onOptionSelected = { item -> - if (selectedItem != item) { - onItemSelected(item) + if (enabled) { + InputDropDownPopup( + isMenuOpen = isMenuOpen, + options = items, + width = width, + verticalOffsetPx = heightInPx, + onMenuOpenChanged = { isMenuOpen = it }, + onOptionSelected = { item -> + if (selectedItem != item) { + onItemSelected(item) + } + }, + item = { item -> + DropdownChipItem(item, selectedItem) } - }, - item = { item -> - DropdownChipItem(item, selectedItem) - } - ) + ) + } } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/IconButton.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/IconButton.kt index a8cc113d15..b523d3aa49 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/IconButton.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/IconButton.kt @@ -16,10 +16,12 @@ package com.instructure.horizon.horizonui.molecules import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon @@ -170,6 +172,55 @@ fun IconButton( } } +@Composable +fun LoadingIconButton( + @DrawableRes iconRes: Int, + loading: Boolean, + modifier: Modifier = Modifier, + size: IconButtonSize = IconButtonSize.NORMAL, + color: IconButtonColor = IconButtonColor.Black, + elevation: Dp? = null, + enabled: Boolean = true, + contentDescription: String? = null, + onClick: () -> Unit = {}, + contentAlignment: Alignment = Alignment.Center, + badge: @Composable (() -> Unit)? = null +) { + Box( + contentAlignment = contentAlignment, + modifier = modifier + .animateContentSize() + ) { + if (loading) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(color = color.backgroundColor, shape = HorizonCornerRadius.level6) + ) { + Spinner( + size = SpinnerSize.EXTRA_SMALL, + color = color.iconColor, + modifier = Modifier + .align(Alignment.Center) + .padding(8.dp), + ) + } + } else { + IconButton( + iconRes = iconRes, + modifier = modifier, + size = size, + color = color, + elevation = elevation, + enabled = enabled, + contentDescription = contentDescription, + onClick = onClick, + badge = badge + ) + } + } +} + @Composable @Preview(showBackground = true) private fun IconButtonPreview() { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/Modal.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/Modal.kt index 1457ee3996..160c91134f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/Modal.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/Modal.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -104,6 +105,7 @@ private fun DialogHeader( Text(text = title, style = HorizonTypography.h3, modifier = Modifier.weight(1f)) IconButton( iconRes = R.drawable.close, + contentDescription = stringResource(R.string.a11y_close), size = IconButtonSize.SMALL, color = IconButtonColor.Inverse, elevation = HorizonElevation.level4, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigation.kt index 3e26a88b00..7691c50df2 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigation.kt @@ -92,10 +92,10 @@ fun HorizonNavigation(navController: NavHostController, modifier: Modifier = Mod snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> NavHost( - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() }, modifier = modifier.padding(innerPadding), navController = navController, startDestination = MainNavigationRoute.Home.route @@ -114,7 +114,12 @@ fun HorizonNavigation(navController: NavHostController, modifier: Modifier = Mod composable(MainNavigationRoute.Home.route) { HomeScreen(navController, hiltViewModel()) } - composable { + composable( + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() } + ) { val viewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsState() ModuleItemSequenceScreen(navController, uiState) @@ -235,4 +240,5 @@ fun HorizonNavigation(navController: NavHostController, modifier: Modifier = Mod } } } -} \ No newline at end of file +} + diff --git a/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigationTransitionRules.kt b/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigationTransitionRules.kt new file mode 100644 index 0000000000..4e67353a42 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigationTransitionRules.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.navigation + +import com.instructure.horizon.features.account.navigation.AccountRoute +import com.instructure.horizon.features.home.HomeNavigationRoute +import com.instructure.horizon.features.inbox.navigation.HorizonInboxRoute +import com.instructure.horizon.features.notebook.navigation.NotebookRoute +import com.instructure.horizon.horizonui.animation.NavigationTransitionAnimation + +data class NavigationTransitionAnimationRule( + val from: String? = null, + val to: String? = null, + val style: NavigationTransitionAnimation, +) + +val animationRules = listOf( + //Notebook + NavigationTransitionAnimationRule( + from = NotebookRoute.Notebook.route, + to = NotebookRoute.EditNotebook.serializableRoute, + style = NavigationTransitionAnimation.SCALE, + ), + NavigationTransitionAnimationRule( + from = NotebookRoute.Notebook.route, + to = NotebookRoute.AddNotebook.serializableRoute, + style = NavigationTransitionAnimation.SCALE + ), + NavigationTransitionAnimationRule( + from = MainNavigationRoute.ModuleItemSequence.serializableRoute, + to = NotebookRoute.Notebook.route, + style = NavigationTransitionAnimation.SCALE + ), + + //Account + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.Advanced.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.BugReportWebView.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.CalendarFeed.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.Notifications.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.Password.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.Profile.route, + style = NavigationTransitionAnimation.SLIDE + ), + + //Inbox + NavigationTransitionAnimationRule( + from = null, + to = HorizonInboxRoute.InboxCompose.route, + style = NavigationTransitionAnimation.SCALE + ), +) + +private val Any.serializableRoute: String + get() = this::class.java.name.replace("$", ".").replace(".Companion", "") \ No newline at end of file diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index 8341112cbd..8e87a2d57e 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -478,4 +478,5 @@ Are you sure you want to discard your changes? Exit Cancel + Delete note \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookRepositoryTest.kt index e73204657d..7e260c291f 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookRepositoryTest.kt @@ -194,6 +194,31 @@ class NotebookRepositoryTest { coVerify { horizonGetCoursesManager.getCoursesWithProgress(userId = 123L, forceNetwork = false) } } + @Test + fun `Test deleteNote calls redwood API with noteId`() = runTest { + coEvery { redwoodApiManager.deleteNote(any()) } returns Unit + + getRepository().deleteNote("note123") + + coVerify(exactly = 1) { redwoodApiManager.deleteNote("note123") } + } + + @Test(expected = Exception::class) + fun `Test deleteNote propagates API errors`() = runTest { + coEvery { redwoodApiManager.deleteNote(any()) } throws Exception("Delete failed") + + getRepository().deleteNote("note123") + } + + @Test + fun `Test deleteNote with empty noteId calls API`() = runTest { + coEvery { redwoodApiManager.deleteNote(any()) } returns Unit + + getRepository().deleteNote("") + + coVerify(exactly = 1) { redwoodApiManager.deleteNote("") } + } + private fun getRepository(): NotebookRepository { return NotebookRepository(redwoodApiManager, horizonGetCoursesManager, apiPrefs) } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookViewModelTest.kt index 6d4f3e107e..b8e3076fff 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookViewModelTest.kt @@ -239,6 +239,78 @@ class NotebookViewModelTest { assertFalse(viewModel.uiState.value.showCourseFilter) } + @Test + fun `Test updateShowDeleteConfirmation updates state correctly`() = runTest { + val viewModel = getViewModel() + val testNote = viewModel.uiState.value.notes.first() + + viewModel.uiState.value.updateShowDeleteConfirmation(testNote) + + assertEquals(testNote, viewModel.uiState.value.showDeleteConfirmationForNote) + } + + @Test + fun `Test updateShowDeleteConfirmation with null clears confirmation`() = runTest { + val viewModel = getViewModel() + val testNote = viewModel.uiState.value.notes.first() + + viewModel.uiState.value.updateShowDeleteConfirmation(testNote) + viewModel.uiState.value.updateShowDeleteConfirmation(null) + + assertNull(viewModel.uiState.value.showDeleteConfirmationForNote) + } + + @Test + fun `Test deleteNote removes note from list after successful deletion`() = runTest { + coEvery { repository.deleteNote(any()) } returns Unit + val viewModel = getViewModel() + val initialNotesCount = viewModel.uiState.value.notes.size + val noteToDelete = viewModel.uiState.value.notes.first() + + viewModel.uiState.value.deleteNote(noteToDelete) + + assertEquals(initialNotesCount - 1, viewModel.uiState.value.notes.size) + assertFalse(viewModel.uiState.value.notes.contains(noteToDelete)) + assertNull(viewModel.uiState.value.deleteLoadingNote) + coVerify(exactly = 1) { repository.deleteNote(noteToDelete.id) } + } + + @Test + fun `Test deleteNote clears loading state on error`() = runTest { + coEvery { repository.deleteNote(any()) } throws Exception("Delete failed") + val viewModel = getViewModel() + val initialNotesCount = viewModel.uiState.value.notes.size + val noteToDelete = viewModel.uiState.value.notes.first() + + viewModel.uiState.value.deleteNote(noteToDelete) + + assertEquals(initialNotesCount, viewModel.uiState.value.notes.size) + assertTrue(viewModel.uiState.value.notes.contains(noteToDelete)) + assertNull(viewModel.uiState.value.deleteLoadingNote) + } + + @Test + fun `Test deleteNote with null note does nothing`() = runTest { + val viewModel = getViewModel() + val initialNotesCount = viewModel.uiState.value.notes.size + + viewModel.uiState.value.deleteNote(null) + + assertEquals(initialNotesCount, viewModel.uiState.value.notes.size) + coVerify(exactly = 0) { repository.deleteNote(any()) } + } + + @Test + fun `Test deleteNote calls repository with correct noteId`() = runTest { + coEvery { repository.deleteNote(any()) } returns Unit + val viewModel = getViewModel() + val noteToDelete = viewModel.uiState.value.notes.first() + + viewModel.uiState.value.deleteNote(noteToDelete) + + coVerify(exactly = 1) { repository.deleteNote(noteToDelete.id) } + } + private fun getViewModel(): NotebookViewModel { return NotebookViewModel(context, repository, savedStateHandle) }