diff --git a/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt b/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt index 2830c1ca57..06592961a3 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt @@ -36,7 +36,10 @@ import com.instructure.canvasapi2.utils.PendoInitCallbackHandler import com.instructure.canvasapi2.utils.weave.apiAsync import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.horizon.HorizonActivity +import com.instructure.ngc.NGCActivity import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.loginapi.login.util.QRLogin.performSSOLogin import com.instructure.loginapi.login.util.QRLogin.verifySSOLoginUri @@ -212,7 +215,12 @@ class InterwebsToApplication : BaseCanvasActivity() { return@tryWeave } else { delay(700) - if (ApiPrefs.canvasCareerView.orDefault()) { + if (RemoteConfigUtils.getBoolean(RemoteConfigParam.NEXT_GEN_CANVAS)) { + val intent = Intent(this@InterwebsToApplication, NGCActivity::class.java) + intent.data = Uri.parse(url) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } else if (ApiPrefs.canvasCareerView.orDefault()) { val intent = Intent(this@InterwebsToApplication, HorizonActivity::class.java) intent.data = Uri.parse(url) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/libs/instui/src/main/java/com/instructure/instui/compose/InstUITheme.kt b/libs/instui/src/main/java/com/instructure/instui/compose/InstUITheme.kt index 65c2988983..8badedcfe8 100644 --- a/libs/instui/src/main/java/com/instructure/instui/compose/InstUITheme.kt +++ b/libs/instui/src/main/java/com/instructure/instui/compose/InstUITheme.kt @@ -21,15 +21,25 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import com.instructure.instui.token.component.InstUIText import com.instructure.instui.token.semantic.InstUISemanticColors +val LocalCourseColor = staticCompositionLocalOf { + Color.Unspecified +} + @Composable -fun InstUITheme(content: @Composable () -> Unit) { +fun InstUITheme( + courseColor: Color = LocalCourseColor.current, + content: @Composable () -> Unit, +) { MaterialTheme { CompositionLocalProvider( LocalTextStyle provides InstUIText.content, LocalContentColor provides InstUISemanticColors.Text.base(), + LocalCourseColor provides courseColor, content = content ) } diff --git a/libs/instui/src/main/java/com/instructure/instui/compose/navigation/CollapsingTopBar.kt b/libs/instui/src/main/java/com/instructure/instui/compose/navigation/CollapsingTopBar.kt new file mode 100644 index 0000000000..897b63fb71 --- /dev/null +++ b/libs/instui/src/main/java/com/instructure/instui/compose/navigation/CollapsingTopBar.kt @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2026 - 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.instui.compose.navigation + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +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.dp +import com.instructure.instui.compose.InstUITheme +import com.instructure.instui.compose.LocalCourseColor +import com.instructure.instui.compose.indicator.Icon +import com.instructure.instui.compose.text.Text +import com.instructure.instui.token.component.InstUIHeading +import com.instructure.instui.token.icon.InstUIIcons +import com.instructure.instui.token.icon.line.ArrowOpenLeft +import com.instructure.instui.token.semantic.InstUILayoutSizes +import com.instructure.instui.token.semantic.InstUISemanticColors + +private val LeadingSize = InstUILayoutSizes.Size.Interactive.height_lg +private val LeadingGap = InstUILayoutSizes.Spacing.SpaceMd.spaceMd +private val TitleEndPadding = InstUILayoutSizes.Spacing.SpaceLg.spaceLg + +/** + * InstUI collapsing top bar with optional leading content. + * + * Wraps Material3 [LargeTopAppBar] with InstUI tokens. When expanded, shows the + * [leading] content (image, color swatch, avatar, etc.) next to the title. As + * the user scrolls, the leading content shrinks and fades, the background + * transitions from base to [accentColor], and text/icon colors transition from + * base to onColor. Status bar icons toggle light/dark automatically. + * + * When the real design system component is ready, only this file's internals change. + * + * Usage: + * ``` + * val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + * + * CollapsingTopBar( + * title = "Course Name", + * scrollBehavior = scrollBehavior, + * onNavigateBack = { navController.popBackStack() }, + * leading = { CourseImage(imageUrl, courseColor) }, + * ) + * ``` + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CollapsingTopBar( + title: String, + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, + accentColor: Color = LocalCourseColor.current, + expandedHeight: Dp = 112.dp, + onNavigateBack: (() -> Unit)? = null, +) { + val collapsedFraction = scrollBehavior.state.collapsedFraction + val expandedFraction = 1f - collapsedFraction + val contentColor = lerp( + InstUISemanticColors.Text.base(), + InstUISemanticColors.Text.onColor(), + collapsedFraction, + ) + + LargeTopAppBar( + modifier = modifier, + expandedHeight = expandedHeight, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (leading != null) { + Box( + modifier = Modifier + .width(LeadingSize * expandedFraction) + .clipToBounds() + .alpha(expandedFraction), + ) { + // Content stays at full size; outer Box clips as it shrinks + Box(modifier = Modifier.requiredWidth(LeadingSize)) { + leading() + } + } + Spacer(modifier = Modifier.width(LeadingGap * expandedFraction)) + } + Text( + text = title, + style = InstUIHeading.titleCardMini, + maxLines = if (collapsedFraction > 0.5f) 1 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(end = TitleEndPadding), + ) + } + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = InstUIIcons.Line.ArrowOpenLeft, + tint = contentColor, + ) + } + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = InstUISemanticColors.Background.base(), + scrolledContainerColor = accentColor, + titleContentColor = contentColor, + navigationIconContentColor = contentColor, + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "CollapsingTopBar — Light", showBackground = true) +@Preview(name = "CollapsingTopBar — Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CollapsingTopBarPreview() { + InstUITheme(courseColor = Color(0xFFBF5811)) { + CollapsingTopBar( + title = "Introduction to Space Stations", + scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(), + onNavigateBack = {}, + ) + } +} \ No newline at end of file diff --git a/libs/instui/src/main/java/com/instructure/instui/compose/navigation/SegmentedControl.kt b/libs/instui/src/main/java/com/instructure/instui/compose/navigation/SegmentedControl.kt new file mode 100644 index 0000000000..8feac3744f --- /dev/null +++ b/libs/instui/src/main/java/com/instructure/instui/compose/navigation/SegmentedControl.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2026 - 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.instui.compose.navigation + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.Color +import com.instructure.instui.compose.InstUITheme +import com.instructure.instui.compose.LocalCourseColor +import com.instructure.instui.compose.text.Text +import com.instructure.instui.token.component.InstUIHeading +import com.instructure.instui.token.component.InstUIText as InstUITextTokens +import com.instructure.instui.token.semantic.InstUISemanticColors + +/** + * InstUI segmented control / tab bar. + * + * Wraps Material3 [SecondaryTabRow] with InstUI token colors and typography. + * When the real design system component is ready, only this file's internals change. + * + * Usage: + * ``` + * SegmentedControl( + * tabs = listOf("Home", "Modules", "My Work", "More"), + * selectedIndex = 1, + * onTabSelected = { index -> }, + * ) + * ``` + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SegmentedControl( + tabs: List, + selectedIndex: Int, + onTabSelected: (Int) -> Unit, + modifier: Modifier = Modifier, + accentColor: Color = LocalCourseColor.current, +) { + SecondaryTabRow( + selectedTabIndex = selectedIndex, + modifier = modifier, + containerColor = InstUISemanticColors.Background.base(), + contentColor = accentColor, + indicator = { + TabRowDefaults.SecondaryIndicator( + modifier = Modifier.tabIndicatorOffset(selectedIndex), + color = accentColor, + ) + }, + divider = {}, + ) { + tabs.forEachIndexed { index, title -> + val selected = index == selectedIndex + Tab( + selected = selected, + onClick = { onTabSelected(index) }, + text = { + Text( + text = title, + style = if (selected) InstUIHeading.titleCardMini else InstUITextTokens.content, + color = if (selected) accentColor else InstUISemanticColors.Text.base(), + ) + }, + ) + } + } +} + +@Preview(name = "SegmentedControl — Light", showBackground = true) +@Preview(name = "SegmentedControl — Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SegmentedControlPreview() { + InstUITheme(courseColor = Color(0xFFBF5811)) { + Column( + modifier = Modifier + .background(InstUISemanticColors.Background.base()) + .padding(16.dp) + ) { + SegmentedControl( + tabs = listOf("Home", "Modules", "My Work", "More"), + selectedIndex = 1, + onTabSelected = {}, + ) + } + } +} \ No newline at end of file diff --git a/libs/ngc/build.gradle.kts b/libs/ngc/build.gradle.kts index 198f4470ce..bf17ea386a 100644 --- a/libs/ngc/build.gradle.kts +++ b/libs/ngc/build.gradle.kts @@ -80,6 +80,7 @@ android { dependencies { implementation(project(":pandautils")) + implementation(project(":instui")) implementation(Libs.NAVIGATION_COMPOSE) implementation(Libs.HILT) diff --git a/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/CourseHomeScreen.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/CourseHomeScreen.kt new file mode 100644 index 0000000000..eef278fab8 --- /dev/null +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/CourseHomeScreen.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2026 - 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.ngc.features.coursehome + +import android.app.Activity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import com.instructure.ngc.features.coursehome.modules.CourseModulesScreen +import com.instructure.ngc.features.coursehome.mywork.CourseMyWorkScreen +import com.instructure.ngc.features.coursehome.navigation.CourseNavigationScreen +import com.instructure.ngc.features.coursehome.overview.CourseOverviewScreen +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.view.WindowInsetsControllerCompat +import androidx.hilt.navigation.compose.hiltViewModel +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.instructure.instui.compose.InstUITheme +import com.instructure.instui.compose.LocalCourseColor +import com.instructure.instui.compose.navigation.CollapsingTopBar +import com.instructure.instui.compose.navigation.SegmentedControl +import com.instructure.instui.token.semantic.InstUILayoutSizes + +private val TAB_LABELS = listOf("Home", "Modules", "My Work", "More") + +@Composable +fun CourseHomeScreen( + onNavigateBack: () -> Unit, + viewModel: CourseHomeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + + CourseHomeScreenContent( + uiState = uiState, + onNavigateBack = onNavigateBack, + onTabSelected = viewModel::onTabSelected, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalGlideComposeApi::class) +@Composable +fun CourseHomeScreenContent( + uiState: CourseHomeUiState, + onNavigateBack: () -> Unit, + onTabSelected: (CourseHomeTab) -> Unit, +) { + val courseColor = LocalCourseColor.current + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val collapsedFraction = scrollBehavior.state.collapsedFraction + + val view = LocalView.current + val lightStatusBar = collapsedFraction < 0.5f + SideEffect { + val window = (view.context as? Activity)?.window ?: return@SideEffect + WindowInsetsControllerCompat(window, view).isAppearanceLightStatusBars = lightStatusBar + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + CollapsingTopBar( + title = uiState.courseName, + scrollBehavior = scrollBehavior, + onNavigateBack = onNavigateBack, + leading = { + CourseImage( + imageUrl = uiState.courseImageUrl, + courseColor = courseColor, + ) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + SegmentedControl( + tabs = TAB_LABELS, + selectedIndex = uiState.selectedTab.ordinal, + onTabSelected = { index -> onTabSelected(CourseHomeTab.entries[index]) }, + modifier = Modifier.fillMaxWidth(), + ) + + when (uiState.selectedTab) { + CourseHomeTab.HOME -> CourseOverviewScreen(modifier = Modifier.fillMaxSize()) + CourseHomeTab.MODULES -> CourseModulesScreen(modifier = Modifier.fillMaxSize()) + CourseHomeTab.MY_WORK -> CourseMyWorkScreen(modifier = Modifier.fillMaxSize()) + CourseHomeTab.MORE -> CourseNavigationScreen(modifier = Modifier.fillMaxSize()) + } + } + } +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun CourseImage( + imageUrl: String?, + courseColor: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(InstUILayoutSizes.Size.Interactive.height_lg) + .clip(RoundedCornerShape(InstUILayoutSizes.BorderRadius.Md.md)) + .background(courseColor), + ) { + if (imageUrl != null) { + GlideImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } + } +} + +@Preview +@Composable +private fun CourseHomeScreenPreview() { + InstUITheme(courseColor = Color(0xFFBF5811)) { + CourseHomeScreenContent( + uiState = CourseHomeUiState( + courseName = "Introduction to Space Stations with an aggressively long name to test collapsing behavior.", + selectedTab = CourseHomeTab.HOME, + isLoading = false, + ), + onNavigateBack = {}, + onTabSelected = {}, + ) + } +} \ No newline at end of file diff --git a/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/CourseHomeUiState.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/CourseHomeUiState.kt new file mode 100644 index 0000000000..08952e2317 --- /dev/null +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/CourseHomeUiState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - 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.ngc.features.coursehome + +data class CourseHomeUiState( + val courseName: String = "", + val courseImageUrl: String? = null, + val selectedTab: CourseHomeTab = CourseHomeTab.HOME, + val isLoading: Boolean = true, + val isError: Boolean = false, +) + +enum class CourseHomeTab { + HOME, + MODULES, + MY_WORK, + MORE; +} \ No newline at end of file diff --git a/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/CourseHomeViewModel.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/CourseHomeViewModel.kt new file mode 100644 index 0000000000..b4fc6fcc41 --- /dev/null +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/CourseHomeViewModel.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 - 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.ngc.features.coursehome + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.pandautils.domain.usecase.courses.LoadCourseUseCase +import com.instructure.pandautils.domain.usecase.courses.LoadCourseUseCaseParams +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 CourseHomeViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val loadCourseUseCase: LoadCourseUseCase +) : ViewModel() { + + private val courseId: Long = savedStateHandle.get(ARG_COURSE_ID) ?: 0L + + private val _uiState = MutableStateFlow(CourseHomeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadCourse() + } + + fun onTabSelected(tab: CourseHomeTab) { + _uiState.update { it.copy(selectedTab = tab) } + } + + private fun loadCourse() { + viewModelScope.launch { + try { + val course = loadCourseUseCase( + LoadCourseUseCaseParams(courseId, false) + ) + _uiState.update { + it.copy( + courseName = course.name, + courseImageUrl = course.imageUrl, + isLoading = false, + ) + } + } catch (e: Exception) { + _uiState.update { it.copy(isLoading = false, isError = true) } + } + } + } + + companion object { + const val ARG_COURSE_ID = "courseId" + } +} \ No newline at end of file diff --git a/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/modules/CourseModulesScreen.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/modules/CourseModulesScreen.kt new file mode 100644 index 0000000000..da08f1f427 --- /dev/null +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/modules/CourseModulesScreen.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 - 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.ngc.features.coursehome.modules + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.instructure.instui.compose.text.Text + +@Composable +fun CourseModulesScreen(modifier: Modifier = Modifier) { + LazyColumn(modifier = modifier.fillMaxSize()) { + // Placeholder — content will be added in a future ticket + item { + Text("Course Modules content coming soon!") + } + } +} \ No newline at end of file diff --git a/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/mywork/CourseMyWorkScreen.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/mywork/CourseMyWorkScreen.kt new file mode 100644 index 0000000000..2beb0000ea --- /dev/null +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/mywork/CourseMyWorkScreen.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 - 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.ngc.features.coursehome.mywork + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.instructure.instui.compose.text.Text + +@Composable +fun CourseMyWorkScreen(modifier: Modifier = Modifier) { + LazyColumn(modifier = modifier.fillMaxSize()) { + // Placeholder — content will be added in a future ticket + item { + Text("Course My Work content coming soon!") + } + } +} \ No newline at end of file diff --git a/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/navigation/CourseNavigationScreen.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/navigation/CourseNavigationScreen.kt new file mode 100644 index 0000000000..6586addb9b --- /dev/null +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/navigation/CourseNavigationScreen.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 - 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.ngc.features.coursehome.navigation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.instructure.instui.compose.text.Text + +@Composable +fun CourseNavigationScreen(modifier: Modifier = Modifier) { + LazyColumn(modifier = modifier.fillMaxSize()) { + // Placeholder — content will be added in a future ticket + item { + Text("Course Navigation content coming soon!") + } + } +} \ No newline at end of file diff --git a/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/overview/CourseOverviewScreen.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/overview/CourseOverviewScreen.kt new file mode 100644 index 0000000000..562d71a1f4 --- /dev/null +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/coursehome/overview/CourseOverviewScreen.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 - 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.ngc.features.coursehome.overview + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.instructure.instui.compose.text.Text + +@Composable +fun CourseOverviewScreen(modifier: Modifier = Modifier) { + LazyColumn(modifier = modifier.fillMaxSize()) { + // Placeholder — content will be added in a future ticket + item { + Text("Course Overview content coming soon!") + } + } +} \ No newline at end of file diff --git a/libs/ngc/src/main/java/com/instructure/ngc/dashboard/NGCDashboardScreen.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/dashboard/NGCDashboardScreen.kt similarity index 99% rename from libs/ngc/src/main/java/com/instructure/ngc/dashboard/NGCDashboardScreen.kt rename to libs/ngc/src/main/java/com/instructure/ngc/features/dashboard/NGCDashboardScreen.kt index 3c45225cdf..965cdee7c9 100644 --- a/libs/ngc/src/main/java/com/instructure/ngc/dashboard/NGCDashboardScreen.kt +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/dashboard/NGCDashboardScreen.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.ngc.dashboard +package com.instructure.ngc.features.dashboard import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/libs/ngc/src/main/java/com/instructure/ngc/splash/SplashScreen.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/splash/SplashScreen.kt similarity index 98% rename from libs/ngc/src/main/java/com/instructure/ngc/splash/SplashScreen.kt rename to libs/ngc/src/main/java/com/instructure/ngc/features/splash/SplashScreen.kt index 7d4bb34675..16830ddf23 100644 --- a/libs/ngc/src/main/java/com/instructure/ngc/splash/SplashScreen.kt +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/splash/SplashScreen.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.instructure.ngc.splash +package com.instructure.ngc.features.splash import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/libs/ngc/src/main/java/com/instructure/ngc/splash/SplashUiState.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/splash/SplashUiState.kt similarity index 95% rename from libs/ngc/src/main/java/com/instructure/ngc/splash/SplashUiState.kt rename to libs/ngc/src/main/java/com/instructure/ngc/features/splash/SplashUiState.kt index 96fc3b5bc2..8e013ac28c 100644 --- a/libs/ngc/src/main/java/com/instructure/ngc/splash/SplashUiState.kt +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/splash/SplashUiState.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.instructure.ngc.splash +package com.instructure.ngc.features.splash import com.instructure.canvasapi2.models.CanvasTheme diff --git a/libs/ngc/src/main/java/com/instructure/ngc/splash/SplashViewModel.kt b/libs/ngc/src/main/java/com/instructure/ngc/features/splash/SplashViewModel.kt similarity index 98% rename from libs/ngc/src/main/java/com/instructure/ngc/splash/SplashViewModel.kt rename to libs/ngc/src/main/java/com/instructure/ngc/features/splash/SplashViewModel.kt index 43c32f8cf6..522a117ccb 100644 --- a/libs/ngc/src/main/java/com/instructure/ngc/splash/SplashViewModel.kt +++ b/libs/ngc/src/main/java/com/instructure/ngc/features/splash/SplashViewModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.instructure.ngc.splash +package com.instructure.ngc.features.splash import android.content.Context import androidx.lifecycle.ViewModel diff --git a/libs/ngc/src/main/java/com/instructure/ngc/navigation/NGCComposeNavigationHandler.kt b/libs/ngc/src/main/java/com/instructure/ngc/navigation/NGCComposeNavigationHandler.kt index 50d52fbfad..6cbc452b8d 100644 --- a/libs/ngc/src/main/java/com/instructure/ngc/navigation/NGCComposeNavigationHandler.kt +++ b/libs/ngc/src/main/java/com/instructure/ngc/navigation/NGCComposeNavigationHandler.kt @@ -36,7 +36,7 @@ class NGCComposeNavigationHandler( override fun handleCoursesNavigation(event: DashboardNavigationEvent.Courses) { when (event) { is DashboardNavigationEvent.Courses.NavigateToCourse -> { - Log.d(TAG, "NavigateToCourse: courseId=${event.course.id}, courseName=${event.course.name}") + navController.navigate(NGCNavigationRoute.CourseHome.createRoute(event.course.id)) } is DashboardNavigationEvent.Courses.NavigateToGroup -> { Log.d(TAG, "NavigateToGroup: groupId=${event.group.id}, groupName=${event.group.name}") diff --git a/libs/ngc/src/main/java/com/instructure/ngc/navigation/NGCNavigation.kt b/libs/ngc/src/main/java/com/instructure/ngc/navigation/NGCNavigation.kt index 914ca8705d..a31ce85153 100644 --- a/libs/ngc/src/main/java/com/instructure/ngc/navigation/NGCNavigation.kt +++ b/libs/ngc/src/main/java/com/instructure/ngc/navigation/NGCNavigation.kt @@ -16,23 +16,37 @@ package com.instructure.ngc.navigation +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import com.instructure.ngc.dashboard.NGCDashboardScreen -import com.instructure.ngc.splash.SplashScreen -import com.instructure.ngc.splash.SplashViewModel +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import com.instructure.canvasapi2.models.Course +import com.instructure.instui.compose.InstUITheme +import com.instructure.ngc.features.coursehome.CourseHomeScreen +import com.instructure.ngc.features.coursehome.CourseHomeViewModel +import com.instructure.ngc.features.dashboard.NGCDashboardScreen +import com.instructure.ngc.features.splash.SplashScreen +import com.instructure.ngc.features.splash.SplashViewModel +import com.instructure.pandautils.utils.ColorKeeper import kotlinx.serialization.Serializable @Serializable sealed class NGCNavigationRoute(val route: String) { data object Splash : NGCNavigationRoute("splash") data object Dashboard : NGCNavigationRoute("dashboard") + data object CourseHome : NGCNavigationRoute("courses/{${CourseHomeViewModel.ARG_COURSE_ID}}") { + fun createRoute(courseId: Long) = "courses/$courseId" + } } @Composable @@ -62,5 +76,31 @@ fun NGCNavigation(navController: NavHostController, modifier: Modifier = Modifie composable(NGCNavigationRoute.Dashboard.route) { NGCDashboardScreen(navController) } + + composable( + route = NGCNavigationRoute.CourseHome.route, + arguments = listOf( + navArgument(CourseHomeViewModel.ARG_COURSE_ID) { type = NavType.LongType } + ), + deepLinks = listOf( + navDeepLink { uriPattern = "https://{domain}/courses/{${CourseHomeViewModel.ARG_COURSE_ID}}" }, + navDeepLink { uriPattern = "http://{domain}/courses/{${CourseHomeViewModel.ARG_COURSE_ID}}" }, + navDeepLink { uriPattern = "canvas-courses://{domain}/courses/{${CourseHomeViewModel.ARG_COURSE_ID}}" }, + navDeepLink { uriPattern = "canvas-student://{domain}/courses/{${CourseHomeViewModel.ARG_COURSE_ID}}" }, + ) + ) { backStackEntry -> + val courseId = backStackEntry.arguments?.getLong(CourseHomeViewModel.ARG_COURSE_ID) ?: 0L + val isDark = isSystemInDarkTheme() + val themedColor = remember(courseId) { + ColorKeeper.getOrGenerateColor(Course(id = courseId)) + } + val courseColor = Color(if (isDark) themedColor.dark else themedColor.light) + + InstUITheme(courseColor = courseColor) { + CourseHomeScreen( + onNavigateBack = { navController.popBackStack() }, + ) + } + } } } diff --git a/libs/ngc/src/test/java/com/instructure/ngc/coursehome/CourseHomeViewModelTest.kt b/libs/ngc/src/test/java/com/instructure/ngc/coursehome/CourseHomeViewModelTest.kt new file mode 100644 index 0000000000..75344db950 --- /dev/null +++ b/libs/ngc/src/test/java/com/instructure/ngc/coursehome/CourseHomeViewModelTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2026 - 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.ngc.coursehome + +import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.models.Course +import com.instructure.ngc.features.coursehome.CourseHomeTab +import com.instructure.ngc.features.coursehome.CourseHomeViewModel +import com.instructure.pandautils.domain.usecase.courses.LoadCourseUseCase +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.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CourseHomeViewModelTest { + + private val loadCourseUseCase: LoadCourseUseCase = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Initial state is loading`() = runTest { + coEvery { loadCourseUseCase(any()) } coAnswers { + kotlinx.coroutines.delay(100) + Course(id = 1L, name = "Test Course") + } + + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.isLoading) + assertFalse(viewModel.uiState.value.isError) + } + + @Test + fun `Successful course load updates state with course name and image`() = runTest { + val course = Course(id = 1L, name = "Biology 101", imageUrl = "https://example.com/image.jpg") + coEvery { loadCourseUseCase(any()) } returns course + + val viewModel = getViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(state.isError) + assertEquals("Biology 101", state.courseName) + assertEquals("https://example.com/image.jpg", state.courseImageUrl) + } + + @Test + fun `Course without image has null imageUrl`() = runTest { + val course = Course(id = 1L, name = "Chemistry 201", imageUrl = null) + coEvery { loadCourseUseCase(any()) } returns course + + val viewModel = getViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertEquals("Chemistry 201", state.courseName) + assertNull(state.courseImageUrl) + } + + @Test + fun `Failed course load sets error state`() = runTest { + coEvery { loadCourseUseCase(any()) } throws RuntimeException("Network error") + + val viewModel = getViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertTrue(state.isError) + } + + @Test + fun `Default selected tab is HOME`() = runTest { + coEvery { loadCourseUseCase(any()) } returns Course(id = 1L, name = "Test") + + val viewModel = getViewModel() + + assertEquals(CourseHomeTab.HOME, viewModel.uiState.value.selectedTab) + } + + @Test + fun `onTabSelected updates selected tab`() = runTest { + coEvery { loadCourseUseCase(any()) } returns Course(id = 1L, name = "Test") + + val viewModel = getViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.onTabSelected(CourseHomeTab.MODULES) + assertEquals(CourseHomeTab.MODULES, viewModel.uiState.value.selectedTab) + + viewModel.onTabSelected(CourseHomeTab.MY_WORK) + assertEquals(CourseHomeTab.MY_WORK, viewModel.uiState.value.selectedTab) + + viewModel.onTabSelected(CourseHomeTab.MORE) + assertEquals(CourseHomeTab.MORE, viewModel.uiState.value.selectedTab) + + viewModel.onTabSelected(CourseHomeTab.HOME) + assertEquals(CourseHomeTab.HOME, viewModel.uiState.value.selectedTab) + } + + private fun getViewModel(courseId: Long = 1L): CourseHomeViewModel { + val savedStateHandle = SavedStateHandle(mapOf(CourseHomeViewModel.ARG_COURSE_ID to courseId)) + return CourseHomeViewModel(savedStateHandle, loadCourseUseCase) + } +} \ No newline at end of file diff --git a/libs/ngc/src/test/java/com/instructure/ngc/splash/SplashViewModelTest.kt b/libs/ngc/src/test/java/com/instructure/ngc/splash/SplashViewModelTest.kt index fc1fc29d7e..0e54778c4e 100644 --- a/libs/ngc/src/test/java/com/instructure/ngc/splash/SplashViewModelTest.kt +++ b/libs/ngc/src/test/java/com/instructure/ngc/splash/SplashViewModelTest.kt @@ -23,6 +23,7 @@ import com.instructure.canvasapi2.models.CanvasTheme import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.ngc.features.splash.SplashViewModel import com.instructure.pandautils.domain.usecase.splash.LoadSplashDataUseCase import com.instructure.pandautils.domain.usecase.splash.SetupPendoTrackingUseCase import com.instructure.pandautils.domain.usecase.splash.SplashData