Skip to content

Commit cfc408f

Browse files
authored
[CLX-2863][Horizon] Lti tools (#3249)
refs: CLX-2863 affects: Student release note: none * External tools screen. * Fixed tab indicator. * Fixed tab scroll when changing pages. * Hide tools tab if there is no LTI tool.
1 parent 77e2680 commit cfc408f

File tree

13 files changed

+444
-9
lines changed

13 files changed

+444
-9
lines changed

libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ExternalToolAPI.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,29 @@ import com.instructure.canvasapi2.StatusCallback
2121
import com.instructure.canvasapi2.builders.RestBuilder
2222
import com.instructure.canvasapi2.builders.RestParams
2323
import com.instructure.canvasapi2.models.LTITool
24+
import com.instructure.canvasapi2.utils.DataResult
2425

2526
import retrofit2.Call
2627
import retrofit2.http.GET
2728
import retrofit2.http.Path
2829
import retrofit2.http.Query
30+
import retrofit2.http.Tag
2931
import retrofit2.http.Url
3032

3133

32-
internal object ExternalToolAPI {
34+
object ExternalToolAPI {
3335
// This returns a paged response, so either we have to depaginate, or pull down 100, since we're typically looking
3436
// for the Studio LTI.
35-
internal interface ExternalToolInterface {
37+
interface ExternalToolInterface {
3638
@GET("{contextId}/external_tools?include_parents=true")
3739
fun getExternalToolsForCanvasContext(@Path("contextId") contextId: Long): Call<List<LTITool>>
3840

3941
@GET("external_tools/visible_course_nav_tools")
4042
fun getExternalToolsForCourses(@Query("context_codes[]", encoded = true) contextCodes: List<String>): Call<List<LTITool>>
4143

44+
@GET("external_tools/visible_course_nav_tools")
45+
suspend fun getExternalToolsForCourses(@Query("context_codes[]", encoded = true) contextCodes: List<String>, @Tag params: RestParams): DataResult<List<LTITool>>
46+
4247
@GET
4348
fun getLtiFromUrl(@Url url: String): Call<LTITool>
4449
}

libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.instructure.canvasapi2.apis.DiscussionAPI
1515
import com.instructure.canvasapi2.apis.DomainServicesAuthenticationAPI
1616
import com.instructure.canvasapi2.apis.EnrollmentAPI
1717
import com.instructure.canvasapi2.apis.ExperienceAPI
18+
import com.instructure.canvasapi2.apis.ExternalToolAPI
1819
import com.instructure.canvasapi2.apis.FeaturesAPI
1920
import com.instructure.canvasapi2.apis.FileDownloadAPI
2021
import com.instructure.canvasapi2.apis.FileFolderAPI
@@ -440,6 +441,11 @@ class ApiModule {
440441
fun provideExperienceAPI(): ExperienceAPI {
441442
return RestBuilder().build(ExperienceAPI::class.java, RestParams())
442443
}
444+
445+
@Provides
446+
fun provideExternalToolApi(): ExternalToolAPI.ExternalToolInterface {
447+
return RestBuilder().build(ExternalToolAPI.ExternalToolInterface::class.java, RestParams())
448+
}
443449
}
444450

445451
@EarlyEntryPoint

libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnScreen.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ import com.instructure.canvasapi2.managers.CourseWithProgress
6969
import com.instructure.canvasapi2.utils.ContextKeeper
7070
import com.instructure.horizon.R
7171
import com.instructure.horizon.features.learn.course.CourseDetailsScreen
72-
import com.instructure.horizon.features.learn.course.CourseDetailsUiState
72+
import com.instructure.horizon.features.learn.course.CourseDetailsViewModel
7373
import com.instructure.horizon.features.learn.program.ProgramDetailsScreen
7474
import com.instructure.horizon.features.learn.program.ProgramDetailsViewModel
7575
import com.instructure.horizon.horizonui.foundation.HorizonColors
@@ -204,7 +204,12 @@ private fun LearnScreenWrapper(
204204
)
205205
when {
206206
(state.selectedLearningItem is LearningItem.CourseItem) -> {
207-
CourseDetailsScreen(CourseDetailsUiState(state.selectedLearningItem.courseWithProgress), mainNavController)
207+
val courseDetailsViewModel = hiltViewModel<CourseDetailsViewModel>()
208+
LaunchedEffect(state.selectedLearningItem) {
209+
courseDetailsViewModel.loadState(state.selectedLearningItem.courseWithProgress)
210+
}
211+
val courseDetailsState by courseDetailsViewModel.state.collectAsState()
212+
CourseDetailsScreen(courseDetailsState, mainNavController)
208213
}
209214

210215
(state.selectedLearningItem is LearningItem.ProgramDetails) -> {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (C) 2025 - present Instructure, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.instructure.horizon.features.learn.course
17+
18+
import com.instructure.canvasapi2.apis.ExternalToolAPI
19+
import com.instructure.canvasapi2.builders.RestParams
20+
import com.instructure.canvasapi2.models.CanvasContext
21+
import javax.inject.Inject
22+
23+
class CourseDetailsRepository @Inject constructor(
24+
private val externalToolApi: ExternalToolAPI.ExternalToolInterface
25+
) {
26+
suspend fun hasExternalTools(courseId: Long, forceNetwork: Boolean): Boolean {
27+
return externalToolApi.getExternalToolsForCourses(
28+
listOf(CanvasContext.emptyCourseContext(courseId).contextId),
29+
RestParams(isForceReadFromNetwork = forceNetwork)
30+
).dataOrNull.orEmpty().isNotEmpty()
31+
}
32+
}

libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsScreen.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import androidx.compose.ui.draw.scale
3636
import androidx.compose.ui.res.stringResource
3737
import androidx.compose.ui.unit.dp
3838
import androidx.navigation.NavHostController
39+
import com.instructure.horizon.features.learn.course.lti.CourseToolsScreen
3940
import com.instructure.horizon.features.learn.course.note.CourseNotesScreen
4041
import com.instructure.horizon.features.learn.course.overview.CourseOverviewScreen
4142
import com.instructure.horizon.features.learn.course.progress.CourseProgressScreen
@@ -118,6 +119,11 @@ fun CourseDetailsScreen(
118119
mainNavController,
119120
Modifier.clip(RoundedCornerShape(cornerAnimation))
120121
)
122+
123+
4 -> CourseToolsScreen(
124+
state.selectedCourse?.courseId ?: -1,
125+
Modifier.clip(RoundedCornerShape(cornerAnimation))
126+
)
121127
}
122128
}
123129
}
@@ -135,6 +141,7 @@ private fun Tab(tab: CourseDetailsTab, isSelected: Boolean, modifier: Modifier =
135141
Box(
136142
contentAlignment = Alignment.Center,
137143
modifier = modifier
144+
.padding(bottom = 2.dp)
138145
) {
139146
Text(
140147
stringResource(tab.titleRes),

libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/CourseDetailsUiState.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ data class CourseDetailsUiState(
2525
val availableTabs: List<CourseDetailsTab> = CourseDetailsTab.entries,
2626
)
2727

28-
enum class CourseDetailsTab(@StringRes val titleRes: Int, ) {
28+
enum class CourseDetailsTab(@StringRes val titleRes: Int) {
2929
Overview(titleRes = R.string.overview),
3030
MyProgress(titleRes = R.string.myProgress),
3131
Scores(titleRes = R.string.scores),
3232
Notes(titleRes = R.string.notes),
33+
Tools(titleRes = R.string.tools),
3334
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright (C) 2025 - present Instructure, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.instructure.horizon.features.learn.course
17+
18+
import androidx.lifecycle.ViewModel
19+
import androidx.lifecycle.viewModelScope
20+
import com.instructure.canvasapi2.managers.CourseWithProgress
21+
import dagger.hilt.android.lifecycle.HiltViewModel
22+
import kotlinx.coroutines.flow.MutableStateFlow
23+
import kotlinx.coroutines.flow.asStateFlow
24+
import kotlinx.coroutines.flow.update
25+
import kotlinx.coroutines.launch
26+
import javax.inject.Inject
27+
28+
@HiltViewModel
29+
class CourseDetailsViewModel @Inject constructor(
30+
private val repository: CourseDetailsRepository
31+
) : ViewModel() {
32+
33+
private val _uiState = MutableStateFlow(CourseDetailsUiState())
34+
val state = _uiState.asStateFlow()
35+
36+
fun loadState(courseWithProgress: CourseWithProgress) {
37+
viewModelScope.launch {
38+
_uiState.update {
39+
it.copy(
40+
selectedCourse = courseWithProgress,
41+
availableTabs = it.availableTabs.minus(CourseDetailsTab.Tools)
42+
)
43+
}
44+
45+
val hasTools = repository.hasExternalTools(courseWithProgress.courseId, false)
46+
47+
_uiState.update {
48+
it.copy(
49+
availableTabs = if (hasTools) CourseDetailsTab.entries else it.availableTabs
50+
)
51+
}
52+
}
53+
}
54+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright (C) 2025 - present Instructure, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.instructure.horizon.features.learn.course.lti
17+
18+
import com.instructure.canvasapi2.apis.ExternalToolAPI
19+
import com.instructure.canvasapi2.builders.RestParams
20+
import com.instructure.canvasapi2.models.CanvasContext
21+
import com.instructure.canvasapi2.models.LTITool
22+
import javax.inject.Inject
23+
24+
class CourseToolsRepository @Inject constructor(
25+
private val externalToolApi: ExternalToolAPI.ExternalToolInterface
26+
) {
27+
suspend fun getExternalTools(courseId: Long, forceNetwork: Boolean): List<LTITool> {
28+
return externalToolApi.getExternalToolsForCourses(
29+
listOf(CanvasContext.emptyCourseContext(courseId).contextId),
30+
RestParams(isForceReadFromNetwork = forceNetwork)
31+
).dataOrThrow
32+
}
33+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright (C) 2025 - present Instructure, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.instructure.horizon.features.learn.course.lti
17+
18+
import androidx.compose.foundation.background
19+
import androidx.compose.foundation.clickable
20+
import androidx.compose.foundation.layout.PaddingValues
21+
import androidx.compose.foundation.layout.Row
22+
import androidx.compose.foundation.layout.defaultMinSize
23+
import androidx.compose.foundation.layout.padding
24+
import androidx.compose.foundation.layout.size
25+
import androidx.compose.foundation.lazy.LazyColumn
26+
import androidx.compose.foundation.lazy.items
27+
import androidx.compose.foundation.shape.CircleShape
28+
import androidx.compose.material3.ExperimentalMaterial3Api
29+
import androidx.compose.material3.Icon
30+
import androidx.compose.material3.Text
31+
import androidx.compose.runtime.Composable
32+
import androidx.compose.runtime.LaunchedEffect
33+
import androidx.compose.runtime.collectAsState
34+
import androidx.compose.runtime.getValue
35+
import androidx.compose.runtime.mutableStateOf
36+
import androidx.compose.runtime.saveable.rememberSaveable
37+
import androidx.compose.runtime.setValue
38+
import androidx.compose.ui.Alignment
39+
import androidx.compose.ui.Modifier
40+
import androidx.compose.ui.draw.clip
41+
import androidx.compose.ui.layout.ContentScale
42+
import androidx.compose.ui.platform.LocalContext
43+
import androidx.compose.ui.res.painterResource
44+
import androidx.compose.ui.tooling.preview.Preview
45+
import androidx.compose.ui.unit.dp
46+
import androidx.hilt.navigation.compose.hiltViewModel
47+
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
48+
import com.bumptech.glide.integration.compose.GlideImage
49+
import com.instructure.horizon.R
50+
import com.instructure.horizon.horizonui.foundation.HorizonColors
51+
import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius
52+
import com.instructure.horizon.horizonui.foundation.HorizonElevation
53+
import com.instructure.horizon.horizonui.foundation.HorizonSpace
54+
import com.instructure.horizon.horizonui.foundation.HorizonTypography
55+
import com.instructure.horizon.horizonui.foundation.SpaceSize
56+
import com.instructure.horizon.horizonui.foundation.horizonShadow
57+
import com.instructure.horizon.horizonui.platform.LoadingStateWrapper
58+
import com.instructure.pandautils.utils.ThemePrefs
59+
import com.instructure.pandautils.utils.getActivityOrNull
60+
import com.instructure.pandautils.utils.launchCustomTab
61+
62+
@OptIn(ExperimentalMaterial3Api::class)
63+
@Composable
64+
fun CourseToolsScreen(
65+
courseId: Long,
66+
modifier: Modifier = Modifier,
67+
viewModel: CourseToolsViewModel = hiltViewModel()
68+
) {
69+
val state by viewModel.uiState.collectAsState()
70+
var previousCourseId: Long? by rememberSaveable { mutableStateOf(null) }
71+
LaunchedEffect(courseId) {
72+
if (courseId != previousCourseId) {
73+
previousCourseId = courseId
74+
viewModel.loadState(courseId)
75+
}
76+
}
77+
78+
LoadingStateWrapper(state.screenState, modifier = modifier.padding(horizontal = 8.dp)) {
79+
CourseToolsContent(state)
80+
}
81+
}
82+
83+
@Composable
84+
private fun CourseToolsContent(uiState: CourseToolsUiState, modifier: Modifier = Modifier) {
85+
LazyColumn(contentPadding = PaddingValues(top = 8.dp), modifier = modifier) {
86+
items(uiState.ltiTools) { ltiTool ->
87+
LtiToolItem(ltiTool)
88+
}
89+
}
90+
}
91+
92+
@OptIn(ExperimentalGlideComposeApi::class)
93+
@Composable
94+
private fun LtiToolItem(
95+
ltiTool: LtiToolItem,
96+
modifier: Modifier = Modifier
97+
) {
98+
val activity = LocalContext.current.getActivityOrNull()
99+
Row(
100+
verticalAlignment = Alignment.CenterVertically,
101+
modifier = modifier
102+
.padding(bottom = 16.dp, start = 8.dp, end = 8.dp)
103+
.defaultMinSize(minHeight = 64.dp)
104+
.horizonShadow(HorizonElevation.level4, shape = HorizonCornerRadius.level6, clip = false)
105+
.background(HorizonColors.Surface.pageSecondary(), shape = HorizonCornerRadius.level6)
106+
.clip(HorizonCornerRadius.level6)
107+
.clickable {
108+
activity?.launchCustomTab(ltiTool.url, ThemePrefs.brandColor)
109+
}
110+
.padding(vertical = 8.dp, horizontal = 12.dp)
111+
) {
112+
GlideImage(
113+
model = ltiTool.iconUrl,
114+
contentDescription = ltiTool.title,
115+
modifier = Modifier
116+
.size(32.dp)
117+
.clip(CircleShape),
118+
contentScale = ContentScale.Crop
119+
)
120+
HorizonSpace(SpaceSize.SPACE_8)
121+
Text(
122+
text = ltiTool.title,
123+
style = HorizonTypography.p2,
124+
modifier = Modifier.weight(1f)
125+
)
126+
Icon(
127+
painterResource(R.drawable.open_in_new),
128+
contentDescription = null,
129+
tint = HorizonColors.Icon.default(),
130+
modifier = Modifier.size(20.dp)
131+
)
132+
}
133+
}
134+
135+
@Preview
136+
@Composable
137+
private fun CourseToolsScreenPreview() {
138+
CourseToolsContent(
139+
uiState = CourseToolsUiState(
140+
ltiTools = listOf(
141+
LtiToolItem("Tool 1", "https://tool1.com/icon.png", "https://tool1.com/launch"),
142+
LtiToolItem("Tool 2", "https://tool2.com/icon.png", "https://tool2.com/launch"),
143+
LtiToolItem("Tool 3", "https://tool3.com/icon.png", "https://tool3.com/launch"),
144+
)
145+
)
146+
)
147+
}

0 commit comments

Comments
 (0)