Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<LTITool>>

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

@GET("external_tools/visible_course_nav_tools")
suspend fun getExternalToolsForCourses(@Query("context_codes[]", encoded = true) contextCodes: List<String>, @Tag params: RestParams): DataResult<List<LTITool>>

@GET
fun getLtiFromUrl(@Url url: String): Call<LTITool>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -204,7 +204,12 @@ private fun LearnScreenWrapper(
)
when {
(state.selectedLearningItem is LearningItem.CourseItem) -> {
CourseDetailsScreen(CourseDetailsUiState(state.selectedLearningItem.courseWithProgress), mainNavController)
val courseDetailsViewModel = hiltViewModel<CourseDetailsViewModel>()
LaunchedEffect(state.selectedLearningItem) {
courseDetailsViewModel.loadState(state.selectedLearningItem.courseWithProgress)
}
val courseDetailsState by courseDetailsViewModel.state.collectAsState()
CourseDetailsScreen(courseDetailsState, mainNavController)
}

(state.selectedLearningItem is LearningItem.ProgramDetails) -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -118,6 +119,11 @@ fun CourseDetailsScreen(
mainNavController,
Modifier.clip(RoundedCornerShape(cornerAnimation))
)

4 -> CourseToolsScreen(
state.selectedCourse?.courseId ?: -1,
Modifier.clip(RoundedCornerShape(cornerAnimation))
)
}
}
}
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ data class CourseDetailsUiState(
val availableTabs: List<CourseDetailsTab> = 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),
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<LTITool> {
return externalToolApi.getExternalToolsForCourses(
listOf(CanvasContext.emptyCourseContext(courseId).contextId),
RestParams(isForceReadFromNetwork = forceNetwork)
).dataOrThrow
}
}
Original file line number Diff line number Diff line change
@@ -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"),
)
)
)
}
Loading