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)
}