diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt index 761898e5e3..9633cedf30 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt @@ -4,6 +4,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter import com.instructure.student.features.dashboard.compose.DashboardScreenContent import com.instructure.student.features.dashboard.compose.DashboardUiState import kotlinx.coroutines.flow.MutableSharedFlow @@ -17,6 +19,13 @@ class DashboardScreenTest { @get:Rule val composeTestRule = createComposeRule() + private val mockRouter = object : DashboardRouter { + override fun routeToGlobalAnnouncement(subject: String, message: String) {} + override fun routeToSubmissionDetails(canvasContext: CanvasContext, assignmentId: Long, attemptId: Long) {} + override fun routeToMyFiles(canvasContext: CanvasContext, folderId: Long) {} + override fun routeToSyncProgress() {} + } + @Test fun testDashboardScreenShowsLoadingState() { val mockUiState = DashboardUiState( @@ -32,7 +41,8 @@ class DashboardScreenTest { uiState = mockUiState, refreshSignal = MutableSharedFlow(), snackbarMessageFlow = MutableSharedFlow(), - onShowSnackbar = { _, _, _ -> } + onShowSnackbar = { _, _, _ -> }, + router = mockRouter ) } @@ -55,7 +65,8 @@ class DashboardScreenTest { uiState = mockUiState, refreshSignal = MutableSharedFlow(), snackbarMessageFlow = MutableSharedFlow(), - onShowSnackbar = { _, _, _ -> } + onShowSnackbar = { _, _, _ -> }, + router = mockRouter ) } @@ -78,7 +89,8 @@ class DashboardScreenTest { uiState = mockUiState, refreshSignal = MutableSharedFlow(), snackbarMessageFlow = MutableSharedFlow(), - onShowSnackbar = { _, _, _ -> } + onShowSnackbar = { _, _, _ -> }, + router = mockRouter ) } @@ -101,7 +113,8 @@ class DashboardScreenTest { uiState = mockUiState, refreshSignal = MutableSharedFlow(), snackbarMessageFlow = MutableSharedFlow(), - onShowSnackbar = { _, _, _ -> } + onShowSnackbar = { _, _, _ -> }, + router = mockRouter ) } diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt index 0aef31261c..5e17ba3868 100644 --- a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt @@ -24,12 +24,17 @@ import androidx.compose.ui.platform.ComposeView import com.instructure.canvasapi2.models.CanvasContext import com.instructure.interactions.router.Route import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter import com.instructure.student.fragment.ParentFragment import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class DashboardFragment : ParentFragment() { + @Inject + lateinit var router: DashboardRouter + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -39,7 +44,7 @@ class DashboardFragment : ParentFragment() { return ComposeView(requireContext()).apply { setContent { CanvasTheme { - DashboardScreen() + DashboardScreen(router = router) } } } diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt index 722b231d0f..bb57b76751 100644 --- a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt @@ -56,15 +56,17 @@ import com.instructure.pandautils.compose.composables.CanvasThemedAppBar import com.instructure.pandautils.compose.composables.EmptyContent import com.instructure.pandautils.compose.composables.ErrorContent import com.instructure.pandautils.compose.composables.Loading +import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata import com.instructure.pandautils.features.dashboard.widget.courseinvitation.CourseInvitationsWidget +import com.instructure.pandautils.features.dashboard.widget.institutionalannouncements.InstitutionalAnnouncementsWidget import com.instructure.student.R import com.instructure.student.activity.NavigationActivity import com.instructure.student.features.dashboard.widget.welcome.WelcomeWidget import kotlinx.coroutines.flow.SharedFlow @Composable -fun DashboardScreen() { +fun DashboardScreen(router: DashboardRouter) { val viewModel: DashboardViewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsState() @@ -72,7 +74,8 @@ fun DashboardScreen() { uiState = uiState, refreshSignal = viewModel.refreshSignal, snackbarMessageFlow = viewModel.snackbarMessage, - onShowSnackbar = viewModel::showSnackbar + onShowSnackbar = viewModel::showSnackbar, + router = router ) } @@ -82,7 +85,8 @@ fun DashboardScreenContent( uiState: DashboardUiState, refreshSignal: SharedFlow, snackbarMessageFlow: SharedFlow, - onShowSnackbar: (String, String?, (() -> Unit)?) -> Unit + onShowSnackbar: (String, String?, (() -> Unit)?) -> Unit, + router: DashboardRouter ) { val activity = LocalActivity.current val pullRefreshState = rememberPullRefreshState( @@ -158,6 +162,7 @@ fun DashboardScreenContent( widgets = uiState.widgets, refreshSignal = refreshSignal, onShowSnackbar = onShowSnackbar, + router = router, modifier = Modifier.fillMaxSize() ) } @@ -180,6 +185,7 @@ private fun WidgetGrid( widgets: List, refreshSignal: SharedFlow, onShowSnackbar: (String, String?, (() -> Unit)?) -> Unit, + router: DashboardRouter, modifier: Modifier = Modifier ) { val activity = LocalActivity.current ?: return @@ -209,7 +215,7 @@ private fun WidgetGrid( } } ) { metadata -> - GetWidgetComposable(metadata.id, refreshSignal, columns, onShowSnackbar) + GetWidgetComposable(metadata.id, refreshSignal, columns, onShowSnackbar, router) } } } @@ -219,7 +225,8 @@ private fun GetWidgetComposable( widgetId: String, refreshSignal: SharedFlow, columns: Int, - onShowSnackbar: (String, String?, (() -> Unit)?) -> Unit + onShowSnackbar: (String, String?, (() -> Unit)?) -> Unit, + router: DashboardRouter ) { return when (widgetId) { WidgetMetadata.WIDGET_ID_WELCOME -> WelcomeWidget(refreshSignal = refreshSignal) @@ -228,6 +235,11 @@ private fun GetWidgetComposable( columns = columns, onShowSnackbar = onShowSnackbar ) + WidgetMetadata.WIDGET_ID_INSTITUTIONAL_ANNOUNCEMENTS -> InstitutionalAnnouncementsWidget( + refreshSignal = refreshSignal, + columns = columns, + onAnnouncementClick = router::routeToGlobalAnnouncement + ) else -> {} } } \ No newline at end of file diff --git a/libs/pandares/src/main/res/drawable/ic_calendar_solid.xml b/libs/pandares/src/main/res/drawable/ic_calendar_solid.xml new file mode 100644 index 0000000000..22ff01c2e4 --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_calendar_solid.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/libs/pandares/src/main/res/drawable/ic_question_solid.xml b/libs/pandares/src/main/res/drawable/ic_question_solid.xml new file mode 100644 index 0000000000..73ac11a32a --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_question_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/drawable/ic_warning_solid.xml b/libs/pandares/src/main/res/drawable/ic_warning_solid.xml new file mode 100644 index 0000000000..4f00b1ea8d --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_warning_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 0beaeb9d76..e3e761ce13 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -2229,4 +2229,7 @@ Invitation to %s declined Decline Invitation Are you sure you want to decline the invitation to %s? + + + Announcements (%d) diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidgetTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidgetTest.kt new file mode 100644 index 0000000000..ef82a01192 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidgetTest.kt @@ -0,0 +1,384 @@ +/* + * 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.pandautils.features.dashboard.widget.institutionalannouncements + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Date + +@RunWith(AndroidJUnit4::class) +class InstitutionalAnnouncementsWidgetTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testWidgetDoesNotShowWhenLoading() { + val uiState = InstitutionalAnnouncementsUiState( + loading = true, + error = false, + announcements = emptyList() + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements", substring = true).assertDoesNotExist() + } + + @Test + fun testWidgetDoesNotShowWhenError() { + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = true, + announcements = emptyList() + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements", substring = true).assertDoesNotExist() + } + + @Test + fun testWidgetDoesNotShowWhenNoAnnouncements() { + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = emptyList() + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements", substring = true).assertDoesNotExist() + } + + @Test + fun testWidgetShowsSingleAnnouncement() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Campus Maintenance", + message = "Campus will be closed for maintenance", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements (1)").assertIsDisplayed() + composeTestRule.onNodeWithText("Campus Maintenance").assertIsDisplayed() + composeTestRule.onNodeWithText("Test University").assertIsDisplayed() + } + + @Test + fun testWidgetShowsMultipleAnnouncements() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Campus Maintenance", + message = "Message 1", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2L, + subject = "New Semester", + message = "Message 2", + institutionName = "Test University", + startDate = Date(), + icon = "calendar", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 3L, + subject = "System Update", + message = "Message 3", + institutionName = "Test University", + startDate = Date(), + icon = "warning", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements (3)").assertIsDisplayed() + composeTestRule.onNodeWithText("Campus Maintenance").assertIsDisplayed() + + // Swipe to second page + composeTestRule.onRoot().performTouchInput { + swipeLeft( + startX = centerX + (width / 4), + endX = centerX - (width / 4) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("New Semester").assertIsDisplayed() + + // Swipe to third page + composeTestRule.onRoot().performTouchInput { + swipeLeft( + startX = centerX + (width / 4), + endX = centerX - (width / 4) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("System Update").assertIsDisplayed() + } + + @Test + fun testAnnouncementCardClickCallsCallback() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Test Announcement", + message = "Test Message", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + var clickCalled = false + var clickedSubject: String? = null + var clickedMessage: String? = null + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { subject, message -> + clickCalled = true + clickedSubject = subject + clickedMessage = message + } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Test Announcement").performClick() + + assert(clickCalled) + assert(clickedSubject == "Test Announcement") + assert(clickedMessage == "Test Message") + } + + @Test + fun testWidgetShowsMultipleAnnouncementsInColumns() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Announcement 1", + message = "Message 1", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2L, + subject = "Announcement 2", + message = "Message 2", + institutionName = "Test University", + startDate = Date(), + icon = "calendar", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 3L, + subject = "Announcement 3", + message = "Message 3", + institutionName = "Test University", + startDate = Date(), + icon = "warning", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 2, // This will create 2 pages (2 announcements on first page, 1 on second) + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + // First page should show 2 announcements + composeTestRule.onNodeWithText("Announcement 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Announcement 2").assertIsDisplayed() + + // Swipe to second page + composeTestRule.onRoot().performTouchInput { + swipeLeft( + startX = centerX + (width / 4), + endX = centerX - (width / 4) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcement 3").assertIsDisplayed() + } + + @Test + fun testWidgetShowsAnnouncementWithoutDate() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "No Date Announcement", + message = "Message", + institutionName = "Test University", + startDate = null, + icon = "info", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("No Date Announcement").assertIsDisplayed() + composeTestRule.onNodeWithText("Test University").assertIsDisplayed() + } + + @Test + fun testWidgetShowsPagerIndicatorForMultiplePages() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Announcement 1", + message = "Message 1", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2L, + subject = "Announcement 2", + message = "Message 2", + institutionName = "Test University", + startDate = Date(), + icon = "calendar", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, // This will create 2 pages + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + // First announcement should be visible + composeTestRule.onNodeWithText("Announcement 1").assertIsDisplayed() + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepository.kt new file mode 100644 index 0000000000..a7f26dbf49 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.pandautils.data.repository.accountnotification + +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.DataResult + +interface AccountNotificationRepository { + suspend fun getAccountNotifications( + forceRefresh: Boolean + ): DataResult> + + suspend fun deleteAccountNotification( + accountNotificationId: Long + ): DataResult +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryImpl.kt new file mode 100644 index 0000000000..9a04f70862 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryImpl.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.pandautils.data.repository.accountnotification + +import com.instructure.canvasapi2.apis.AccountNotificationAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.DataResult + +class AccountNotificationRepositoryImpl( + private val accountNotificationApi: AccountNotificationAPI.AccountNotificationInterface +) : AccountNotificationRepository { + + override suspend fun getAccountNotifications( + forceRefresh: Boolean + ): DataResult> { + val params = RestParams( + isForceReadFromNetwork = forceRefresh, + usePerPageQueryParam = true + ) + return accountNotificationApi.getAccountNotifications( + params = params, + includePast = false, + showIsClosed = false + ) + } + + override suspend fun deleteAccountNotification( + accountNotificationId: Long + ): DataResult { + val params = RestParams() + return accountNotificationApi.deleteAccountNotification(accountNotificationId, params) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt index c892cd24b7..2f48fabb51 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt @@ -4,9 +4,8 @@ import com.instructure.canvasapi2.apis.CourseAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.utils.DataResult -import javax.inject.Inject -class CourseRepositoryImpl @Inject constructor( +class CourseRepositoryImpl( private val courseApi: CourseAPI.CoursesInterface ) : CourseRepository { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryImpl.kt index 0bfa237395..8b1dc91c51 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryImpl.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryImpl.kt @@ -20,9 +20,8 @@ import com.instructure.canvasapi2.apis.EnrollmentAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.Enrollment import com.instructure.canvasapi2.utils.DataResult -import javax.inject.Inject -class EnrollmentRepositoryImpl @Inject constructor( +class EnrollmentRepositoryImpl( private val enrollmentApi: EnrollmentAPI.EnrollmentInterface ) : EnrollmentRepository { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepository.kt new file mode 100644 index 0000000000..351858777e --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepository.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.pandautils.data.repository.user + +import com.instructure.canvasapi2.models.Account +import com.instructure.canvasapi2.utils.DataResult + +interface UserRepository { + suspend fun getAccount(forceRefresh: Boolean): DataResult +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepositoryImpl.kt new file mode 100644 index 0000000000..087efa204f --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepositoryImpl.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.pandautils.data.repository.user + +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Account +import com.instructure.canvasapi2.utils.DataResult + +class UserRepositoryImpl( + private val userApi: UserAPI.UsersInterface +) : UserRepository { + + override suspend fun getAccount(forceRefresh: Boolean): DataResult { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return userApi.getAccount(params) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt index e87c1b876b..bd158dbfa8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt @@ -1,11 +1,33 @@ +/* + * 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.pandautils.di +import com.instructure.canvasapi2.apis.AccountNotificationAPI import com.instructure.canvasapi2.apis.CourseAPI import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepository +import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepositoryImpl import com.instructure.pandautils.data.repository.course.CourseRepository import com.instructure.pandautils.data.repository.course.CourseRepositoryImpl import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepository import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepositoryImpl +import com.instructure.pandautils.data.repository.user.UserRepository +import com.instructure.pandautils.data.repository.user.UserRepositoryImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -31,4 +53,20 @@ class RepositoryModule { ): CourseRepository { return CourseRepositoryImpl(courseApi) } + + @Provides + @Singleton + fun provideAccountNotificationRepository( + accountNotificationApi: AccountNotificationAPI.AccountNotificationInterface + ): AccountNotificationRepository { + return AccountNotificationRepositoryImpl(accountNotificationApi) + } + + @Provides + @Singleton + fun provideUserRepository( + userApi: UserAPI.UsersInterface + ): UserRepository { + return UserRepositoryImpl(userApi) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/models/accountnotification/InstitutionalAnnouncement.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/models/accountnotification/InstitutionalAnnouncement.kt new file mode 100644 index 0000000000..706985104a --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/models/accountnotification/InstitutionalAnnouncement.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.pandautils.domain.models.accountnotification + +import java.util.Date + +data class InstitutionalAnnouncement( + val id: Long, + val subject: String, + val message: String, + val institutionName: String, + val startDate: Date?, + val icon: String, + val logoUrl: String +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCase.kt new file mode 100644 index 0000000000..02a24aa8bb --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCase.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.pandautils.domain.usecase.accountnotification + +import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepository +import com.instructure.pandautils.data.repository.user.UserRepository +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement +import com.instructure.pandautils.domain.usecase.BaseUseCase +import com.instructure.pandautils.utils.ThemePrefs +import javax.inject.Inject + +data class LoadInstitutionalAnnouncementsParams( + val forceRefresh: Boolean = false +) + +class LoadInstitutionalAnnouncementsUseCase @Inject constructor( + private val accountNotificationRepository: AccountNotificationRepository, + private val userRepository: UserRepository, + private val themePrefs: ThemePrefs +) : BaseUseCase>() { + + override suspend fun execute(params: LoadInstitutionalAnnouncementsParams): List { + val notifications = accountNotificationRepository.getAccountNotifications( + forceRefresh = params.forceRefresh + ).dataOrThrow + + val account = userRepository.getAccount(forceRefresh = params.forceRefresh).dataOrNull + val institutionName = account?.name.orEmpty() + val logoUrl = themePrefs.mobileLogoUrl + + return notifications + .sortedByDescending { it.startDate } + .take(5) + .map { notification -> + InstitutionalAnnouncement( + id = notification.id, + subject = notification.subject, + message = notification.message, + institutionName = institutionName, + startDate = notification.startDate, + icon = notification.icon, + logoUrl = logoUrl + ) + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt index 4b018cbec2..ee9e75f387 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt @@ -25,6 +25,7 @@ data class WidgetMetadata( ) { companion object { const val WIDGET_ID_COURSE_INVITATIONS = "course_invitations" + const val WIDGET_ID_INSTITUTIONAL_ANNOUNCEMENTS = "institutional_announcements" const val WIDGET_ID_WELCOME = "welcome" } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsUiState.kt new file mode 100644 index 0000000000..21fc5ae9e9 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsUiState.kt @@ -0,0 +1,25 @@ +/* + * 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.pandautils.features.dashboard.widget.institutionalannouncements + +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement + +data class InstitutionalAnnouncementsUiState( + val loading: Boolean = true, + val error: Boolean = false, + val announcements: List = emptyList(), + val onRefresh: () -> Unit = {} +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModel.kt new file mode 100644 index 0000000000..f55c7ef6b0 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModel.kt @@ -0,0 +1,70 @@ +/* + * 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.pandautils.features.dashboard.widget.institutionalannouncements + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.pandautils.domain.usecase.accountnotification.LoadInstitutionalAnnouncementsParams +import com.instructure.pandautils.domain.usecase.accountnotification.LoadInstitutionalAnnouncementsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class InstitutionalAnnouncementsViewModel @Inject constructor( + private val loadInstitutionalAnnouncementsUseCase: LoadInstitutionalAnnouncementsUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow( + InstitutionalAnnouncementsUiState( + onRefresh = ::loadAnnouncements + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadAnnouncements() + } + + private fun loadAnnouncements() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = false) } + try { + val announcements = loadInstitutionalAnnouncementsUseCase( + LoadInstitutionalAnnouncementsParams(forceRefresh = true) + ) + _uiState.update { + it.copy( + loading = false, + error = false, + announcements = announcements + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + loading = false, + error = true + ) + } + } + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt new file mode 100644 index 0000000000..df568450b1 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt @@ -0,0 +1,343 @@ +/* + * 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.pandautils.features.dashboard.widget.institutionalannouncements + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import androidx.compose.ui.graphics.Color +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.composables.PagerIndicator +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement +import com.instructure.pandautils.utils.ThemePrefs +import kotlinx.coroutines.flow.SharedFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun InstitutionalAnnouncementsWidget( + refreshSignal: SharedFlow, + columns: Int, + onAnnouncementClick: (String, String) -> Unit, + modifier: Modifier = Modifier +) { + val viewModel: InstitutionalAnnouncementsViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(refreshSignal) { + refreshSignal.collect { + uiState.onRefresh() + } + } + + InstitutionalAnnouncementsContent( + modifier = modifier, + uiState = uiState, + columns = columns, + onAnnouncementClick = onAnnouncementClick + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun InstitutionalAnnouncementsContent( + modifier: Modifier = Modifier, + uiState: InstitutionalAnnouncementsUiState, + columns: Int, + onAnnouncementClick: (String, String) -> Unit +) { + if (uiState.loading || uiState.error || uiState.announcements.isEmpty()) { + return + } + + val announcementPages = uiState.announcements.chunked(columns) + + Column(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + text = stringResource(R.string.institutionalAnnouncementsTitle, uiState.announcements.size), + fontSize = 14.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.Normal, + color = colorResource(R.color.textDarkest) + ) + + val pagerState = rememberPagerState(pageCount = { announcementPages.size }) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + pageSpacing = 8.dp, + contentPadding = PaddingValues(start = 16.dp, end = 24.dp), + beyondViewportPageCount = 1 + ) { page -> + val announcementsInPage = announcementPages[page] + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + announcementsInPage.forEach { announcement -> + AnnouncementCard( + announcement = announcement, + onClick = { onAnnouncementClick(announcement.subject, announcement.message) }, + modifier = Modifier.weight(1f) + ) + } + repeat(columns - announcementsInPage.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + + if (announcementPages.size > 1) { + PagerIndicator( + pagerState = pagerState, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 16.dp), + activeColor = colorResource(R.color.backgroundDarkest), + inactiveColor = colorResource(R.color.backgroundDarkest).copy(alpha = 0.4f) + ) + } else { + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun AnnouncementCard( + announcement: InstitutionalAnnouncement, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.backgroundLightestElevated) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Box { + if (announcement.logoUrl.isNotEmpty()) { + GlideImage( + model = announcement.logoUrl, + contentDescription = announcement.institutionName, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Inside + ) + } else { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = Color(ThemePrefs.brandColor), + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = announcement.institutionName.take(3).uppercase(), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = colorResource(R.color.white) + ) + } + } + + Box( + modifier = Modifier + .size(16.dp) + .align(Alignment.TopStart) + .offset(x = (-8).dp, y = (-8).dp) + .background(colorResource(R.color.backgroundLightestElevated), CircleShape) + ) { + Icon( + painter = painterResource(id = getIconResource(announcement.icon)), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .align(Alignment.Center), + tint = colorResource( + when (announcement.icon) { + AccountNotification.ACCOUNT_NOTIFICATION_WARNING -> R.color.textWarning + AccountNotification.ACCOUNT_NOTIFICATION_ERROR -> R.color.textDanger + else -> R.color.textInfo + } + ) + ) + } + } + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = announcement.institutionName, + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + color = Color(ThemePrefs.brandColor), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + announcement.startDate?.let { startDate -> + Text( + text = formatDateTime(startDate), + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + color = colorResource(R.color.textDark), + maxLines = 1 + ) + } + + Text( + modifier = Modifier.padding(top = 4.dp), + text = announcement.subject, + fontSize = 16.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + overflow = TextOverflow.Ellipsis, + color = colorResource(R.color.textDarkest), + maxLines = 2 + ) + } + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_down), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = colorResource(R.color.textDark) + ) + } + } +} + +@Composable +private fun getIconResource(icon: String): Int { + return when (icon) { + AccountNotification.ACCOUNT_NOTIFICATION_WARNING -> R.drawable.ic_warning_solid + AccountNotification.ACCOUNT_NOTIFICATION_ERROR -> R.drawable.ic_warning_solid + AccountNotification.ACCOUNT_NOTIFICATION_CALENDAR -> R.drawable.ic_calendar_solid + AccountNotification.ACCOUNT_NOTIFICATION_QUESTION -> R.drawable.ic_question_solid + else -> R.drawable.ic_info_solid + } +} + +private fun formatDateTime(date: Date): String { + val formatter = SimpleDateFormat("d MMM yyyy, h:mm a", Locale.getDefault()) + return formatter.format(date) +} + +@Preview(showBackground = true) +@Preview( + showBackground = true, + backgroundColor = 0xFF0F1316, + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +fun InstitutionalAnnouncementsContentPreview() { + ContextKeeper.appContext = LocalContext.current + InstitutionalAnnouncementsContent( + uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = listOf( + InstitutionalAnnouncement( + id = 1, + subject = "Back to School Ceremony Dress Code", + message = "Canvas will be offline for maintenance...", + institutionName = "Canvas College Sydney", + startDate = Date(), + icon = AccountNotification.ACCOUNT_NOTIFICATION_WARNING, + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2, + subject = "New Feature Release", + message = "We're excited to announce...", + institutionName = "Canvas College Sydney", + startDate = Date(), + icon = AccountNotification.ACCOUNT_NOTIFICATION_CALENDAR, + logoUrl = "" + ) + ) + ), + columns = 1, + onAnnouncementClick = { _, _ -> } + ) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt index cf0180f0d7..2b95cf1ad4 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt @@ -39,8 +39,15 @@ class EnsureDefaultWidgetsUseCase @Inject constructor( isFullWidth = true ), WidgetMetadata( - id = WidgetMetadata.WIDGET_ID_WELCOME, + id = WidgetMetadata.WIDGET_ID_INSTITUTIONAL_ANNOUNCEMENTS, position = 1, + isVisible = true, + isEditable = false, + isFullWidth = true + ), + WidgetMetadata( + id = WidgetMetadata.WIDGET_ID_WELCOME, + position = 2, isVisible = true ) ) diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryTest.kt new file mode 100644 index 0000000000..c2ea6bdb39 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.pandautils.data.repository.accountnotification + +import com.instructure.canvasapi2.apis.AccountNotificationAPI +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.DataResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class AccountNotificationRepositoryTest { + + private val accountNotificationApi: AccountNotificationAPI.AccountNotificationInterface = mockk(relaxed = true) + private lateinit var repository: AccountNotificationRepository + + @Before + fun setup() { + repository = AccountNotificationRepositoryImpl(accountNotificationApi) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `getAccountNotifications returns success with notification list`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z"), + AccountNotification(id = 2L, subject = "Announcement 2", message = "Message 2", icon = "warning", startAt = "1970-01-01T00:00:02Z") + ) + val expected = DataResult.Success(notifications) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + val result = repository.getAccountNotifications(forceRefresh = false) + + assertEquals(expected, result) + coVerify { + accountNotificationApi.getAccountNotifications( + params = match { !it.isForceReadFromNetwork && it.usePerPageQueryParam }, + includePast = false, + showIsClosed = false + ) + } + } + + @Test + fun `getAccountNotifications with forceRefresh passes correct params`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + val expected = DataResult.Success(notifications) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + repository.getAccountNotifications(forceRefresh = true) + + coVerify { + accountNotificationApi.getAccountNotifications( + params = match { it.isForceReadFromNetwork && it.usePerPageQueryParam }, + includePast = false, + showIsClosed = false + ) + } + } + + @Test + fun `getAccountNotifications returns failure`() = runTest { + val expected = DataResult.Fail() + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + val result = repository.getAccountNotifications(forceRefresh = false) + + assertEquals(expected, result) + } + + @Test + fun `getAccountNotifications with forceRefresh false uses cache`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Cached Announcement", message = "Cached Message", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + val expected = DataResult.Success(notifications) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + val result = repository.getAccountNotifications(forceRefresh = false) + + assertEquals(expected, result) + coVerify(exactly = 1) { + accountNotificationApi.getAccountNotifications( + params = match { !it.isForceReadFromNetwork }, + includePast = false, + showIsClosed = false + ) + } + } + + @Test + fun `getAccountNotifications handles empty list`() = runTest { + val expected = DataResult.Success(emptyList()) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + val result = repository.getAccountNotifications(forceRefresh = false) + + assertEquals(expected, result) + assertEquals(0, (result as DataResult.Success).data.size) + } + + @Test + fun `getAccountNotifications handles multiple consecutive calls`() = runTest { + val notifications1 = listOf( + AccountNotification(id = 1L, subject = "First Call", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + val notifications2 = listOf( + AccountNotification(id = 2L, subject = "Second Call", message = "Message 2", icon = "warning", startAt = "1970-01-01T00:00:02Z") + ) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returnsMany listOf(DataResult.Success(notifications1), DataResult.Success(notifications2)) + + val result1 = repository.getAccountNotifications(forceRefresh = false) + val result2 = repository.getAccountNotifications(forceRefresh = true) + + assertEquals("First Call", (result1 as DataResult.Success).data[0].subject) + assertEquals("Second Call", (result2 as DataResult.Success).data[0].subject) + } + + @Test + fun `deleteAccountNotification returns success`() = runTest { + val notification = AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + val expected = DataResult.Success(notification) + coEvery { + accountNotificationApi.deleteAccountNotification(any(), any()) + } returns expected + + val result = repository.deleteAccountNotification(accountNotificationId = 1L) + + assertEquals(expected, result) + coVerify { + accountNotificationApi.deleteAccountNotification(1L, any()) + } + } + + @Test + fun `deleteAccountNotification returns failure`() = runTest { + val expected = DataResult.Fail() + coEvery { + accountNotificationApi.deleteAccountNotification(any(), any()) + } returns expected + + val result = repository.deleteAccountNotification(accountNotificationId = 1L) + + assertEquals(expected, result) + } + + @Test + fun `deleteAccountNotification passes correct id`() = runTest { + val notification = AccountNotification(id = 123L, subject = "Announcement", message = "Message", icon = "info", startAt = "1970-01-01T00:00:01Z") + val expected = DataResult.Success(notification) + coEvery { + accountNotificationApi.deleteAccountNotification(any(), any()) + } returns expected + + val result = repository.deleteAccountNotification(accountNotificationId = 123L) + + assertEquals(expected, result) + coVerify { + accountNotificationApi.deleteAccountNotification(123L, any()) + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/user/UserRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/user/UserRepositoryTest.kt new file mode 100644 index 0000000000..50f042df65 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/user/UserRepositoryTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.pandautils.data.repository.user + +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.canvasapi2.models.Account +import com.instructure.canvasapi2.utils.DataResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class UserRepositoryTest { + + private val userApi: UserAPI.UsersInterface = mockk(relaxed = true) + private lateinit var repository: UserRepository + + @Before + fun setup() { + repository = UserRepositoryImpl(userApi) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `getAccount returns success with account data`() = runTest { + val account = Account(id = 1L, name = "Test Institution") + val expected = DataResult.Success(account) + coEvery { + userApi.getAccount(any()) + } returns expected + + val result = repository.getAccount(forceRefresh = false) + + assertEquals(expected, result) + coVerify { + userApi.getAccount(match { !it.isForceReadFromNetwork }) + } + } + + @Test + fun `getAccount with forceRefresh passes correct params`() = runTest { + val account = Account(id = 1L, name = "Test Institution") + val expected = DataResult.Success(account) + coEvery { + userApi.getAccount(any()) + } returns expected + + repository.getAccount(forceRefresh = true) + + coVerify { + userApi.getAccount(match { it.isForceReadFromNetwork }) + } + } + + @Test + fun `getAccount returns failure`() = runTest { + val expected = DataResult.Fail() + coEvery { + userApi.getAccount(any()) + } returns expected + + val result = repository.getAccount(forceRefresh = false) + + assertEquals(expected, result) + } + + @Test + fun `getAccount with forceRefresh false uses cache`() = runTest { + val account = Account(id = 1L, name = "Cached Institution") + val expected = DataResult.Success(account) + coEvery { + userApi.getAccount(any()) + } returns expected + + val result = repository.getAccount(forceRefresh = false) + + assertEquals(expected, result) + coVerify(exactly = 1) { + userApi.getAccount(match { !it.isForceReadFromNetwork }) + } + } + + @Test + fun `getAccount handles account with empty name`() = runTest { + val account = Account(id = 1L, name = "") + val expected = DataResult.Success(account) + coEvery { + userApi.getAccount(any()) + } returns expected + + val result = repository.getAccount(forceRefresh = false) + + assertEquals(expected, result) + assertEquals("", (result as DataResult.Success).data.name) + } + + @Test + fun `getAccount handles multiple consecutive calls`() = runTest { + val account1 = Account(id = 1L, name = "First Call") + val account2 = Account(id = 1L, name = "Second Call") + coEvery { + userApi.getAccount(any()) + } returnsMany listOf(DataResult.Success(account1), DataResult.Success(account2)) + + val result1 = repository.getAccount(forceRefresh = false) + val result2 = repository.getAccount(forceRefresh = true) + + assertEquals("First Call", (result1 as DataResult.Success).data.name) + assertEquals("Second Call", (result2 as DataResult.Success).data.name) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCaseTest.kt new file mode 100644 index 0000000000..9ca2e259b8 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCaseTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.pandautils.domain.usecase.accountnotification + +import com.instructure.canvasapi2.models.Account +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepository +import com.instructure.pandautils.data.repository.user.UserRepository +import com.instructure.pandautils.utils.ThemePrefs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Date + +class LoadInstitutionalAnnouncementsUseCaseTest { + + private val accountNotificationRepository: AccountNotificationRepository = mockk(relaxed = true) + private val userRepository: UserRepository = mockk(relaxed = true) + private lateinit var useCase: LoadInstitutionalAnnouncementsUseCase + + @Before + fun setup() { + mockkObject(ThemePrefs) + every { ThemePrefs.brandColor } returns 0xFF0000FF.toInt() + every { ThemePrefs.mobileLogoUrl } returns "https://example.com/logo.png" + + useCase = LoadInstitutionalAnnouncementsUseCase( + accountNotificationRepository, + userRepository, + ThemePrefs + ) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `execute returns sorted announcements limited to 5`() = runTest { + val date1 = Date(1000L) + val date2 = Date(2000L) + val date3 = Date(3000L) + val date4 = Date(4000L) + val date5 = Date(5000L) + val date6 = Date(6000L) + + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z"), + AccountNotification(id = 2L, subject = "Announcement 2", message = "Message 2", icon = "warning", startAt = "1970-01-01T00:00:03Z"), + AccountNotification(id = 3L, subject = "Announcement 3", message = "Message 3", icon = "calendar", startAt = "1970-01-01T00:00:06Z"), + AccountNotification(id = 4L, subject = "Announcement 4", message = "Message 4", icon = "question", startAt = "1970-01-01T00:00:02Z"), + AccountNotification(id = 5L, subject = "Announcement 5", message = "Message 5", icon = "error", startAt = "1970-01-01T00:00:05Z"), + AccountNotification(id = 6L, subject = "Announcement 6", message = "Message 6", icon = "info", startAt = "1970-01-01T00:00:04Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(5, result.size) + assertEquals(3L, result[0].id) + assertEquals("Announcement 3", result[0].subject) + assertEquals(5L, result[1].id) + assertEquals(6L, result[2].id) + assertEquals(2L, result[3].id) + assertEquals(4L, result[4].id) + } + + @Test + fun `execute with forceRefresh true passes correct params`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(true) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(true) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + + assertEquals(1, result.size) + } + + @Test + fun `execute returns empty list when no notifications`() = runTest { + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(emptyList()) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(0, result.size) + } + + @Test + fun `execute maps notification fields correctly`() = runTest { + val notifications = listOf( + AccountNotification( + id = 123L, + subject = "Test Subject", + message = "Test Message", + icon = "warning", + startAt = "1970-01-01T00:00:01Z" + ) + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Canvas University")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(1, result.size) + assertEquals(123L, result[0].id) + assertEquals("Test Subject", result[0].subject) + assertEquals("Test Message", result[0].message) + assertEquals("Canvas University", result[0].institutionName) + assertEquals("warning", result[0].icon) + assertEquals("https://example.com/logo.png", result[0].logoUrl) + } + + @Test(expected = IllegalStateException::class) + fun `execute throws exception when repository fails`() = runTest { + coEvery { + accountNotificationRepository.getAccountNotifications(any()) + } returns DataResult.Fail() + + useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + } + + @Test + fun `execute handles notifications with null startDate`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "No Date", message = "Message 1", icon = "info", startAt = ""), + AccountNotification(id = 2L, subject = "With Date", message = "Message 2", icon = "info", startAt = "1970-01-01T00:00:02Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(2, result.size) + assertEquals(2L, result[0].id) + assertEquals(1L, result[1].id) + assertEquals(null, result[1].startDate) + } + + @Test + fun `execute returns only first 5 items when more than 5 notifications`() = runTest { + val notifications = (1..10).map { + AccountNotification( + id = it.toLong(), + subject = "Announcement $it", + message = "Message $it", + icon = "info", + startAt = "1970-01-01T00:00:${it.toString().padStart(2, '0')}Z" + ) + } + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(5, result.size) + assertEquals(10L, result[0].id) + assertEquals(9L, result[1].id) + assertEquals(8L, result[2].id) + assertEquals(7L, result[3].id) + assertEquals(6L, result[4].id) + } + + @Test + fun `execute handles failed user repository call gracefully`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Fail() + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(1, result.size) + assertEquals("", result[0].institutionName) + } + + @Test + fun `execute uses empty logo URL when ThemePrefs mobileLogoUrl is empty`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + + every { ThemePrefs.mobileLogoUrl } returns "" + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(1, result.size) + assertEquals("", result[0].logoUrl) + } + + @Test + fun `execute correctly maps institution name from account`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Instructure University")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(1, result.size) + assertEquals("Instructure University", result[0].institutionName) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModelTest.kt new file mode 100644 index 0000000000..6363081211 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModelTest.kt @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.pandautils.features.dashboard.widget.institutionalannouncements + +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement +import com.instructure.pandautils.domain.usecase.accountnotification.LoadInstitutionalAnnouncementsParams +import com.instructure.pandautils.domain.usecase.accountnotification.LoadInstitutionalAnnouncementsUseCase +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class InstitutionalAnnouncementsViewModelTest { + + private val loadInstitutionalAnnouncementsUseCase: LoadInstitutionalAnnouncementsUseCase = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Initial load shows loading state then success`() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Test Announcement", + message = "Test Message", + institutionName = "", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns announcements + + val viewModel = createViewModel() + + assertFalse(viewModel.uiState.value.loading) + assertFalse(viewModel.uiState.value.error) + assertEquals(1, viewModel.uiState.value.announcements.size) + assertEquals("Test Announcement", viewModel.uiState.value.announcements[0].subject) + } + + @Test + fun `Load error shows error state`() { + coEvery { + loadInstitutionalAnnouncementsUseCase(any()) + } throws Exception("Network error") + + val viewModel = createViewModel() + + assertFalse(viewModel.uiState.value.loading) + assertTrue(viewModel.uiState.value.error) + assertTrue(viewModel.uiState.value.announcements.isEmpty()) + } + + @Test + fun `Refresh loads announcements with forceRefresh`() { + val initialAnnouncements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Initial", + message = "Message", + institutionName = "", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + val refreshedAnnouncements = listOf( + InstitutionalAnnouncement( + id = 2L, + subject = "Refreshed", + message = "Message", + institutionName = "", + startDate = Date(), + icon = "warning", + logoUrl = "" + ) + ) + + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns initialAnnouncements andThen refreshedAnnouncements + + val viewModel = createViewModel() + + assertEquals("Initial", viewModel.uiState.value.announcements[0].subject) + + viewModel.uiState.value.onRefresh() + + assertEquals("Refreshed", viewModel.uiState.value.announcements[0].subject) + } + + @Test + fun `Empty announcements list returns empty state`() { + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns emptyList() + + val viewModel = createViewModel() + + assertFalse(viewModel.uiState.value.loading) + assertFalse(viewModel.uiState.value.error) + assertTrue(viewModel.uiState.value.announcements.isEmpty()) + } + + @Test + fun `Multiple announcements are loaded correctly`() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Announcement 1", + message = "Message 1", + institutionName = "", + startDate = Date(1000L), + icon = "info", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2L, + subject = "Announcement 2", + message = "Message 2", + institutionName = "", + startDate = Date(2000L), + icon = "warning", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 3L, + subject = "Announcement 3", + message = "Message 3", + institutionName = "", + startDate = Date(3000L), + icon = "calendar", + logoUrl = "" + ) + ) + + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns announcements + + val viewModel = createViewModel() + + assertFalse(viewModel.uiState.value.loading) + assertFalse(viewModel.uiState.value.error) + assertEquals(3, viewModel.uiState.value.announcements.size) + assertEquals("Announcement 1", viewModel.uiState.value.announcements[0].subject) + assertEquals("Announcement 2", viewModel.uiState.value.announcements[1].subject) + assertEquals("Announcement 3", viewModel.uiState.value.announcements[2].subject) + } + + @Test + fun `Refresh after error recovers to success state`() { + coEvery { + loadInstitutionalAnnouncementsUseCase(any()) + } throws Exception("Network error") + + val viewModel = createViewModel() + + assertTrue(viewModel.uiState.value.error) + assertTrue(viewModel.uiState.value.announcements.isEmpty()) + + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Recovered", + message = "Message", + institutionName = "", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns announcements + + viewModel.uiState.value.onRefresh() + + assertFalse(viewModel.uiState.value.loading) + assertFalse(viewModel.uiState.value.error) + assertEquals(1, viewModel.uiState.value.announcements.size) + assertEquals("Recovered", viewModel.uiState.value.announcements[0].subject) + } + + private fun createViewModel(): InstitutionalAnnouncementsViewModel { + return InstitutionalAnnouncementsViewModel(loadInstitutionalAnnouncementsUseCase) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt index 9025e21d4f..8ba93514bc 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt @@ -59,7 +59,14 @@ class EnsureDefaultWidgetsUseCaseTest { coVerify { repository.saveMetadata( match { - it.id == "welcome" && it.position == 1 && it.isVisible + it.id == "institutional_announcements" && it.position == 1 && it.isVisible && !it.isEditable + } + ) + } + coVerify { + repository.saveMetadata( + match { + it.id == "welcome" && it.position == 2 && it.isVisible } ) } @@ -69,7 +76,8 @@ class EnsureDefaultWidgetsUseCaseTest { fun `execute does not create widget if it already exists`() = runTest { val existingMetadata = listOf( WidgetMetadata("course_invitations", 0, true, false), - WidgetMetadata("welcome", 1, true) + WidgetMetadata("institutional_announcements", 1, true, false), + WidgetMetadata("welcome", 2, true) ) coEvery { repository.observeAllMetadata() } returns flowOf(existingMetadata) @@ -92,6 +100,11 @@ class EnsureDefaultWidgetsUseCaseTest { match { it.id == "course_invitations" } ) } + coVerify(exactly = 1) { + repository.saveMetadata( + match { it.id == "institutional_announcements" } + ) + } coVerify(exactly = 1) { repository.saveMetadata( match { it.id == "welcome" }