diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ExternalToolAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ExternalToolAPI.kt index 31ef290017..2d627d3f3c 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ExternalToolAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ExternalToolAPI.kt @@ -21,24 +21,29 @@ import com.instructure.canvasapi2.StatusCallback import com.instructure.canvasapi2.builders.RestBuilder import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.LTITool +import com.instructure.canvasapi2.utils.DataResult import retrofit2.Call import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query +import retrofit2.http.Tag import retrofit2.http.Url -internal object ExternalToolAPI { +object ExternalToolAPI { // This returns a paged response, so either we have to depaginate, or pull down 100, since we're typically looking // for the Studio LTI. - internal interface ExternalToolInterface { + interface ExternalToolInterface { @GET("{contextId}/external_tools?include_parents=true") fun getExternalToolsForCanvasContext(@Path("contextId") contextId: Long): Call> @GET("external_tools/visible_course_nav_tools") fun getExternalToolsForCourses(@Query("context_codes[]", encoded = true) contextCodes: List): Call> + @GET("external_tools/visible_course_nav_tools") + suspend fun getExternalToolsForCourses(@Query("context_codes[]", encoded = true) contextCodes: List, @Tag params: RestParams): DataResult> + @GET fun getLtiFromUrl(@Url url: String): Call } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt index c5bc276878..4a4b7a65f6 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt @@ -15,6 +15,7 @@ import com.instructure.canvasapi2.apis.DiscussionAPI import com.instructure.canvasapi2.apis.DomainServicesAuthenticationAPI import com.instructure.canvasapi2.apis.EnrollmentAPI import com.instructure.canvasapi2.apis.ExperienceAPI +import com.instructure.canvasapi2.apis.ExternalToolAPI import com.instructure.canvasapi2.apis.FeaturesAPI import com.instructure.canvasapi2.apis.FileDownloadAPI import com.instructure.canvasapi2.apis.FileFolderAPI @@ -440,6 +441,11 @@ class ApiModule { fun provideExperienceAPI(): ExperienceAPI { return RestBuilder().build(ExperienceAPI::class.java, RestParams()) } + + @Provides + fun provideExternalToolApi(): ExternalToolAPI.ExternalToolInterface { + return RestBuilder().build(ExternalToolAPI.ExternalToolInterface::class.java, RestParams()) + } } @EarlyEntryPoint diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnScreen.kt index 74d087d347..ee2f1fc1cb 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnScreen.kt @@ -69,7 +69,7 @@ import com.instructure.canvasapi2.managers.CourseWithProgress import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R import com.instructure.horizon.features.learn.course.CourseDetailsScreen -import com.instructure.horizon.features.learn.course.CourseDetailsUiState +import com.instructure.horizon.features.learn.course.CourseDetailsViewModel import com.instructure.horizon.features.learn.program.ProgramDetailsScreen import com.instructure.horizon.features.learn.program.ProgramDetailsViewModel import com.instructure.horizon.horizonui.foundation.HorizonColors @@ -204,7 +204,12 @@ private fun LearnScreenWrapper( ) when { (state.selectedLearningItem is LearningItem.CourseItem) -> { - CourseDetailsScreen(CourseDetailsUiState(state.selectedLearningItem.courseWithProgress), mainNavController) + val courseDetailsViewModel = hiltViewModel() + LaunchedEffect(state.selectedLearningItem) { + courseDetailsViewModel.loadState(state.selectedLearningItem.courseWithProgress) + } + val courseDetailsState by courseDetailsViewModel.state.collectAsState() + CourseDetailsScreen(courseDetailsState, mainNavController) } (state.selectedLearningItem is LearningItem.ProgramDetails) -> { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsRepository.kt new file mode 100644 index 0000000000..21d21614fa --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsRepository.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.horizon.features.learn.course + +import com.instructure.canvasapi2.apis.ExternalToolAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.CanvasContext +import javax.inject.Inject + +class CourseDetailsRepository @Inject constructor( + private val externalToolApi: ExternalToolAPI.ExternalToolInterface +) { + suspend fun hasExternalTools(courseId: Long, forceNetwork: Boolean): Boolean { + return externalToolApi.getExternalToolsForCourses( + listOf(CanvasContext.emptyCourseContext(courseId).contextId), + RestParams(isForceReadFromNetwork = forceNetwork) + ).dataOrNull.orEmpty().isNotEmpty() + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsScreen.kt index 4d0d68e5cc..ee492f4ed1 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController +import com.instructure.horizon.features.learn.course.lti.CourseToolsScreen import com.instructure.horizon.features.learn.course.note.CourseNotesScreen import com.instructure.horizon.features.learn.course.overview.CourseOverviewScreen import com.instructure.horizon.features.learn.course.progress.CourseProgressScreen @@ -118,6 +119,11 @@ fun CourseDetailsScreen( mainNavController, Modifier.clip(RoundedCornerShape(cornerAnimation)) ) + + 4 -> CourseToolsScreen( + state.selectedCourse?.courseId ?: -1, + Modifier.clip(RoundedCornerShape(cornerAnimation)) + ) } } } @@ -135,6 +141,7 @@ private fun Tab(tab: CourseDetailsTab, isSelected: Boolean, modifier: Modifier = Box( contentAlignment = Alignment.Center, modifier = modifier + .padding(bottom = 2.dp) ) { Text( stringResource(tab.titleRes), diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsUiState.kt index f07be0d04f..31295bca4a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsUiState.kt @@ -25,9 +25,10 @@ data class CourseDetailsUiState( val availableTabs: List = CourseDetailsTab.entries, ) -enum class CourseDetailsTab(@StringRes val titleRes: Int, ) { +enum class CourseDetailsTab(@StringRes val titleRes: Int) { Overview(titleRes = R.string.overview), MyProgress(titleRes = R.string.myProgress), Scores(titleRes = R.string.scores), Notes(titleRes = R.string.notes), + Tools(titleRes = R.string.tools), } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsViewModel.kt new file mode 100644 index 0000000000..fd90316749 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsViewModel.kt @@ -0,0 +1,54 @@ +/* + * 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.horizon.features.learn.course + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.managers.CourseWithProgress +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CourseDetailsViewModel @Inject constructor( + private val repository: CourseDetailsRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(CourseDetailsUiState()) + val state = _uiState.asStateFlow() + + fun loadState(courseWithProgress: CourseWithProgress) { + viewModelScope.launch { + _uiState.update { + it.copy( + selectedCourse = courseWithProgress, + availableTabs = it.availableTabs.minus(CourseDetailsTab.Tools) + ) + } + + val hasTools = repository.hasExternalTools(courseWithProgress.courseId, false) + + _uiState.update { + it.copy( + availableTabs = if (hasTools) CourseDetailsTab.entries else it.availableTabs + ) + } + } + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsRepository.kt new file mode 100644 index 0000000000..40db9788e5 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsRepository.kt @@ -0,0 +1,33 @@ +/* + * 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.horizon.features.learn.course.lti + +import com.instructure.canvasapi2.apis.ExternalToolAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.LTITool +import javax.inject.Inject + +class CourseToolsRepository @Inject constructor( + private val externalToolApi: ExternalToolAPI.ExternalToolInterface +) { + suspend fun getExternalTools(courseId: Long, forceNetwork: Boolean): List { + return externalToolApi.getExternalToolsForCourses( + listOf(CanvasContext.emptyCourseContext(courseId).contextId), + RestParams(isForceReadFromNetwork = forceNetwork) + ).dataOrThrow + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsScreen.kt new file mode 100644 index 0000000000..c065409c4e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsScreen.kt @@ -0,0 +1,147 @@ +/* + * 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.horizon.features.learn.course.lti + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +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.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.instructure.horizon.R +import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius +import com.instructure.horizon.horizonui.foundation.HorizonElevation +import com.instructure.horizon.horizonui.foundation.HorizonSpace +import com.instructure.horizon.horizonui.foundation.HorizonTypography +import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.foundation.horizonShadow +import com.instructure.horizon.horizonui.platform.LoadingStateWrapper +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.getActivityOrNull +import com.instructure.pandautils.utils.launchCustomTab + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CourseToolsScreen( + courseId: Long, + modifier: Modifier = Modifier, + viewModel: CourseToolsViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + var previousCourseId: Long? by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(courseId) { + if (courseId != previousCourseId) { + previousCourseId = courseId + viewModel.loadState(courseId) + } + } + + LoadingStateWrapper(state.screenState, modifier = modifier.padding(horizontal = 8.dp)) { + CourseToolsContent(state) + } +} + +@Composable +private fun CourseToolsContent(uiState: CourseToolsUiState, modifier: Modifier = Modifier) { + LazyColumn(contentPadding = PaddingValues(top = 8.dp), modifier = modifier) { + items(uiState.ltiTools) { ltiTool -> + LtiToolItem(ltiTool) + } + } +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun LtiToolItem( + ltiTool: LtiToolItem, + modifier: Modifier = Modifier +) { + val activity = LocalContext.current.getActivityOrNull() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .padding(bottom = 16.dp, start = 8.dp, end = 8.dp) + .defaultMinSize(minHeight = 64.dp) + .horizonShadow(HorizonElevation.level4, shape = HorizonCornerRadius.level6, clip = false) + .background(HorizonColors.Surface.pageSecondary(), shape = HorizonCornerRadius.level6) + .clip(HorizonCornerRadius.level6) + .clickable { + activity?.launchCustomTab(ltiTool.url, ThemePrefs.brandColor) + } + .padding(vertical = 8.dp, horizontal = 12.dp) + ) { + GlideImage( + model = ltiTool.iconUrl, + contentDescription = ltiTool.title, + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = ltiTool.title, + style = HorizonTypography.p2, + modifier = Modifier.weight(1f) + ) + Icon( + painterResource(R.drawable.open_in_new), + contentDescription = null, + tint = HorizonColors.Icon.default(), + modifier = Modifier.size(20.dp) + ) + } +} + +@Preview +@Composable +private fun CourseToolsScreenPreview() { + CourseToolsContent( + uiState = CourseToolsUiState( + ltiTools = listOf( + LtiToolItem("Tool 1", "https://tool1.com/icon.png", "https://tool1.com/launch"), + LtiToolItem("Tool 2", "https://tool2.com/icon.png", "https://tool2.com/launch"), + LtiToolItem("Tool 3", "https://tool3.com/icon.png", "https://tool3.com/launch"), + ) + ) + ) +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsUiState.kt new file mode 100644 index 0000000000..dc69d84c85 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsUiState.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.horizon.features.learn.course.lti + +import com.instructure.horizon.horizonui.platform.LoadingState + +data class CourseToolsUiState( + val screenState: LoadingState = LoadingState(), + val courseId: Long = 0, + val ltiTools: List = emptyList(), +) + +data class LtiToolItem( + val title: String, + val iconUrl: String?, + val url: String, +) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsViewModel.kt new file mode 100644 index 0000000000..233dbfdd4b --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsViewModel.kt @@ -0,0 +1,106 @@ +/* + * 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.horizon.features.learn.course.lti + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.utils.weave.catch +import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.R +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class CourseToolsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: CourseToolsRepository, +): ViewModel() { + private val _uiState = MutableStateFlow( + CourseToolsUiState() + ) + val uiState = _uiState.asStateFlow() + + fun loadState(courseId: Long) { + _uiState.update { + it.copy( + screenState = it.screenState.copy(isLoading = true, onRefresh = ::refresh, onSnackbarDismiss = ::dismissSnackbar), + courseId = courseId, + ) + } + viewModelScope.tryLaunch { + getData(courseId) + _uiState.update { + it.copy(screenState = it.screenState.copy(isLoading = false)) + } + } catch { _ -> + _uiState.update { + it.copy( + screenState = it.screenState.copy(isLoading = false, isError = true, errorMessage = context.getString( + R.string.failedToLoadScores + )), + ) + } + } + } + + private suspend fun getData(courseId: Long, forceRefresh: Boolean = false) { + val tools = repository.getExternalTools(courseId, forceRefresh) + if (tools.isNotEmpty()) { + val toolItems = tools.map { tool -> + LtiToolItem( + title = tool.courseNavigation?.text ?: tool.name.orEmpty(), + iconUrl = tool.iconUrl, + url = tool.url.orEmpty(), + ) + } + _uiState.update { + it.copy( + screenState = it.screenState.copy(isError = false, errorMessage = null), + ltiTools = toolItems + ) + } + } else { + _uiState.update { + it.copy( + screenState = it.screenState.copy(isError = true, errorMessage = context.getString( + R.string.tools_noLtiTools + )), + ) + } + } + } + + private fun refresh() { + viewModelScope.tryLaunch { + _uiState.update { it.copy(screenState = it.screenState.copy(isRefreshing = true)) } + getData(uiState.value.courseId, forceRefresh = true) + _uiState.update { it.copy(screenState = it.screenState.copy(isRefreshing = false)) } + } catch { + _uiState.update { it.copy(screenState = it.screenState.copy(snackbarMessage = context.getString(R.string.errorOccurred), isRefreshing = false)) } + } + } + + private fun dismissSnackbar() { + _uiState.update { + it.copy(screenState = it.screenState.copy(snackbarMessage = null)) + } + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/tabrow/TabRow.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/tabrow/TabRow.kt index 3193158592..7ef2dc89ef 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/tabrow/TabRow.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/tabrow/TabRow.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -41,6 +42,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview @@ -48,9 +50,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.instructure.horizon.horizonui.foundation.HorizonColors +import kotlin.math.max @Composable -fun TabRow( +fun TabRow( tabs: List, onTabSelected: (Int) -> Unit, selectedIndex: Int, @@ -75,11 +78,15 @@ fun TabRow( val currentOffset by animateIntAsState( sizes.take(selectedIndex).sumOf { it } + (selectedIndex) * spacingPx - + alignmentOffset - , + + alignmentOffset, label = "IndicatorAnimation" ) + LaunchedEffect(selectedIndex) { + val scrollOffset = sizes.take(selectedIndex).sumOf { it } + (selectedIndex) * spacingPx + alignmentOffset + scrollState.animateScrollTo(scrollOffset) + } + val alignment = when (tabAlignment) { Alignment.Start -> Alignment.CenterStart Alignment.CenterHorizontally -> Alignment.Center @@ -93,6 +100,7 @@ fun TabRow( .onGloballyPositioned { containerWidth = it.size.width } + .clipToBounds() ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -143,7 +151,6 @@ private fun BoxScope.SelectedTabIndicator(modifier: Modifier = Modifier) { modifier = modifier .height(1.dp) .align(Alignment.BottomStart) - .offset(y = 2.dp) .background(HorizonColors.Text.surfaceInverseSecondary()) ) } diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index 435da28e07..f4000d422d 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -66,6 +66,7 @@ Overview Scores Notes + Tools Assignment name (A-Z) Due date (newest first) Name: %1$s @@ -351,4 +352,5 @@ Failed to enroll in course. Failed to load programs and courses. Failed to refresh programs and courses. + There are no external tools for this course. \ No newline at end of file