From baba9ec8b20a0c635a722a797f94a6f3aa506baf Mon Sep 17 00:00:00 2001 From: Tyler Lopez <77797048+Tyler-Lopez@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:45:59 -0700 Subject: [PATCH 1/2] Create Strava Activity from App --- .gitignore | 1 + composeApp/build.gradle.kts | 9 +++ .../com/majotyler/hiittimer/MainActivity.kt | 1 + .../majotyler/hiittimer/data/api/StravaApi.kt | 59 +++++++++++++++++++ .../data/dto/StravaAuthenticationDto.kt | 18 ++++++ .../data/repository/StravaRepository.kt | 29 +++++++++ .../usecase/CreateStravaActivityUseCase.kt | 33 +++++++++++ .../hiittimer/network/HttpClientFactory.kt | 21 +++++++ .../common/navigation/NavigationRoot.kt | 6 +- .../presentation/homeScreen/HomeScreen.kt | 10 ++++ .../presentation/homeScreen/HomeViewEvent.kt | 3 + .../presentation/homeScreen/HomeViewModel.kt | 35 ++++++++++- .../homeScreen/HomeViewModelFactory.kt | 2 + 13 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/api/StravaApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/dto/StravaAuthenticationDto.kt create mode 100644 composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/repository/StravaRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/domain/usecase/CreateStravaActivityUseCase.kt create mode 100644 composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/network/HttpClientFactory.kt diff --git a/.gitignore b/.gitignore index adfa9bf..e9d7c9a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ xcuserdata !src/**/build/ local.properties +StravaClientSecret.kt .idea .DS_Store captures diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b852425..0225d45 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -30,8 +30,17 @@ kotlin { androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) + + implementation("io.ktor:ktor-client-okhttp:3.4.1") } + val ktorVersion = "3.4.1" + commonMain.dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") + implementation(compose.runtime) implementation(compose.foundation) implementation(compose.materialIconsExtended) diff --git a/composeApp/src/androidMain/kotlin/com/majotyler/hiittimer/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/majotyler/hiittimer/MainActivity.kt index 0cd2733..89d74e0 100644 --- a/composeApp/src/androidMain/kotlin/com/majotyler/hiittimer/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/majotyler/hiittimer/MainActivity.kt @@ -24,6 +24,7 @@ class MainActivity : ComponentActivity() { MaterialTheme { NavigationRoot( urlOpener = AndroidUrlOpener(context = this), + stravaAccessToken = accessToken, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/api/StravaApi.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/api/StravaApi.kt new file mode 100644 index 0000000..c4ea7ab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/api/StravaApi.kt @@ -0,0 +1,59 @@ +package com.majotyler.hiittimer.data.api + +import com.majotyler.hiittimer.data.dto.StravaAuthenticationDto +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.forms.FormDataContent +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpHeaders +import io.ktor.http.Parameters + +class StravaApi(private val client: HttpClient) { + suspend fun getAccessToken( + clientId: Int, + clientSecret: String, + code: String, + ): StravaAuthenticationDto { + return client.post(urlString = StravaEndpoints.OAUTH_TOKEN) { + parameter(key = "client_id", value = clientId) + parameter(key = "client_secret", value = clientSecret) + parameter(key = "code", value = code) + parameter(key = "grant_type", value = StravaGrantTypes.AUTH_CODE) + }.body() + } + + suspend fun createActivity( + accessToken: String, + name: String, + type: String, + startDateLocal: String, + elapsedTime: Int, + ) { + return client.post(urlString = StravaEndpoints.ACTIVITIES) { + header(key = HttpHeaders.Authorization, value = "Bearer $accessToken") + setBody( + body = FormDataContent( + formData = Parameters.build { + append(name = "name", value = name) + append(name = "sport_type", value = type) + append(name = "start_date_local", value = startDateLocal) + append(name = "elapsed_time", value = elapsedTime.toString()) + } + ) + ) + }.body() + } +} + +private object StravaEndpoints { + const val BASE_URL = "https://www.strava.com" + const val OAUTH_TOKEN = "$BASE_URL/oauth/token" + const val ACTIVITIES = "$BASE_URL/api/v3/activities" +} + +private object StravaGrantTypes { + const val AUTH_CODE = "authorization_code" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/dto/StravaAuthenticationDto.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/dto/StravaAuthenticationDto.kt new file mode 100644 index 0000000..3ff8c93 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/dto/StravaAuthenticationDto.kt @@ -0,0 +1,18 @@ +package com.majotyler.hiittimer.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class StravaAuthenticationDto( + @SerialName("token_type") + val tokenType: String, + @SerialName("expires_at") + val expiresAt: Long, + @SerialName("expires_in") + val expiresIn: Int, + @SerialName("refresh_token") + val refreshToken: String, + @SerialName("access_token") + val accessToken: String, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/repository/StravaRepository.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/repository/StravaRepository.kt new file mode 100644 index 0000000..de19eac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/data/repository/StravaRepository.kt @@ -0,0 +1,29 @@ +package com.majotyler.hiittimer.data.repository + +import com.majotyler.hiittimer.data.api.StravaApi +import com.majotyler.hiittimer.data.dto.StravaAuthenticationDto +import com.majotyler.hiittimer.network.StravaClientSecret + +class StravaRepository( + private val api: StravaApi +) { + suspend fun getAccessToken( + code: String, + ): StravaAuthenticationDto { + return api.getAccessToken( + clientId = StravaClientSecret.STRAVA_CLIENT_ID, + clientSecret = StravaClientSecret.STRAVA_CLIENT_SECRET, + code = code, + ) + } + + suspend fun createActivity( + accessToken: String, + name: String, + type: String, + startDateLocal: String, + elapsedTime: Int, + ) { + return api.createActivity(accessToken, name, type, startDateLocal, elapsedTime) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/domain/usecase/CreateStravaActivityUseCase.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/domain/usecase/CreateStravaActivityUseCase.kt new file mode 100644 index 0000000..99d3a1c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/domain/usecase/CreateStravaActivityUseCase.kt @@ -0,0 +1,33 @@ +package com.majotyler.hiittimer.domain.usecase + +import com.majotyler.hiittimer.data.repository.StravaRepository +import com.majotyler.hiittimer.network.StravaClientSecret +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.hours +import kotlin.time.ExperimentalTime + +class CreateStravaActivityUseCase( + private val stravaAccessCode: String, + private val repository: StravaRepository, +) { + @OptIn(ExperimentalTime::class) + suspend operator fun invoke() { + val accessToken = repository.getAccessToken(code = stravaAccessCode) + + repository.createActivity( + accessToken = accessToken.accessToken, + name = "Created from HIIT App", + type = "HighIntensityIntervalTraining", + startDateLocal = TimeZone.currentSystemDefault() + .let { tz -> + Clock.System.now() + .minus(1.hours) + .toLocalDateTime(tz) + .toString() + }, + elapsedTime = 60, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/network/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/network/HttpClientFactory.kt new file mode 100644 index 0000000..676adc3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/network/HttpClientFactory.kt @@ -0,0 +1,21 @@ +package com.majotyler.hiittimer.network + +import io.ktor.client.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + +object HttpClientFactory { + + fun create(): HttpClient { + return HttpClient { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + } + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/common/navigation/NavigationRoot.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/common/navigation/NavigationRoot.kt index 16dde3d..deb1b05 100644 --- a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/common/navigation/NavigationRoot.kt +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/common/navigation/NavigationRoot.kt @@ -33,10 +33,12 @@ import kotlin.uuid.Uuid @Composable fun NavigationRoot( urlOpener: UrlOpener, + stravaAccessToken: String?, ) { HiitAppTheme { HiitNavDisplay( urlOpener = urlOpener, + stravaAccessToken = stravaAccessToken, ) } } @@ -45,6 +47,7 @@ fun NavigationRoot( @Composable private fun HiitNavDisplay( urlOpener: UrlOpener, + stravaAccessToken: String?, ) { val resultBus = remember { ResultEventBus() } @@ -102,7 +105,8 @@ private fun HiitNavDisplay( backStack.add(element = Route.BuildWorkouts) } } - } + }, + stravaAccessToken = stravaAccessToken, ) HomeScreen( diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeScreen.kt index c5e4893..de7ae0c 100644 --- a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeScreen.kt @@ -44,5 +44,15 @@ fun HomeScreen( ) { Text(text = "Connect with Strava") } + + if (viewModel.showCreateActivityButton) { + Button( + onClick = { + viewModel.onEvent(event = HomeViewEvent.ClickedCreateStravaActivity) + } + ) { + Text(text = "Create Strava Activity") + } + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewEvent.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewEvent.kt index 56bcefd..03a2315 100644 --- a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewEvent.kt +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewEvent.kt @@ -2,5 +2,8 @@ package com.majotyler.hiittimer.presentation.homeScreen sealed interface HomeViewEvent { data object ClickedConnectWithStrava : HomeViewEvent + + // TODO This is temporary just to demonstrate + data object ClickedCreateStravaActivity : HomeViewEvent data object ClickedLaunchBuildWorkouts : HomeViewEvent } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewModel.kt index c4101a5..a44f307 100644 --- a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewModel.kt @@ -1,9 +1,12 @@ package com.majotyler.hiittimer.presentation.homeScreen -import androidx.compose.ui.text.input.KeyboardType.Companion.Uri import androidx.lifecycle.ViewModel import io.ktor.http.* import androidx.lifecycle.viewModelScope +import com.majotyler.hiittimer.data.api.StravaApi +import com.majotyler.hiittimer.data.repository.StravaRepository +import com.majotyler.hiittimer.domain.usecase.CreateStravaActivityUseCase +import com.majotyler.hiittimer.network.HttpClientFactory import com.majotyler.hiittimer.presentation.common.navigation.Router import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -11,24 +14,54 @@ import kotlinx.coroutines.launch class HomeViewModel( private val router: Router, + private val stravaAccessCode: String?, ) : ViewModel() { private val _openUrl = MutableSharedFlow() val openUrl = _openUrl.asSharedFlow() + // TODO This is temporary to show something to create a Strava Activity + val showCreateActivityButton = stravaAccessCode != null + + private val httpClient by lazy { + HttpClientFactory.create() + } + + private val stravaApi: StravaApi by lazy { + StravaApi(httpClient) + } + + private val stravaRepository by lazy { + StravaRepository(stravaApi) + } + private val createStravaActivityUseCase by lazy { + CreateStravaActivityUseCase( + stravaAccessCode = stravaAccessCode ?: "", + repository = stravaRepository, + ) + } + fun onEvent(event: HomeViewEvent) { when (event) { is HomeViewEvent.ClickedConnectWithStrava -> onClickedConnectWithStrava() + is HomeViewEvent.ClickedCreateStravaActivity -> onClickedCreateStravaActivity() is HomeViewEvent.ClickedLaunchBuildWorkouts -> onClickedLaunchBuildWorkouts() } } private fun onClickedConnectWithStrava() { + viewModelScope.launch { _openUrl.emit(value = getAuthUri()) } } + private fun onClickedCreateStravaActivity() { + viewModelScope.launch { + createStravaActivityUseCase() + } + } + private fun onClickedLaunchBuildWorkouts() { router.routeTo(destination = HomeDestination.NavigateToBuildWorkouts) } diff --git a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewModelFactory.kt b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewModelFactory.kt index 51a0bc6..53ae907 100644 --- a/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewModelFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/majotyler/hiittimer/presentation/homeScreen/HomeViewModelFactory.kt @@ -7,10 +7,12 @@ import kotlin.reflect.KClass class HomeViewModelFactory( private val router: (HomeDestination) -> Unit, + private val stravaAccessToken: String?, ) : ViewModelProvider.Factory { override fun create(modelClass: KClass, extras: CreationExtras): T { return HomeViewModel( router = router, + stravaAccessCode = stravaAccessToken, ) as T } } \ No newline at end of file From 447f8f63d92a94d2c0ce0bd24d9dc71fa7ac2eb8 Mon Sep 17 00:00:00 2001 From: Tyler Lopez <77797048+Tyler-Lopez@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:29:34 -0700 Subject: [PATCH 2/2] Add libraries correctly through the toml --- composeApp/build.gradle.kts | 18 ++++++------------ gradle/libs.versions.toml | 8 ++++++++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0225d45..b8670ba 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -30,17 +30,9 @@ kotlin { androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) - - implementation("io.ktor:ktor-client-okhttp:3.4.1") + implementation(libs.ktor.client.okhttp) } - val ktorVersion = "3.4.1" - commonMain.dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") - implementation("io.ktor:ktor-client-core:$ktorVersion") - implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") - implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") - implementation(compose.runtime) implementation(compose.foundation) implementation(compose.materialIconsExtended) @@ -50,11 +42,13 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) - implementation(libs.jetbrains.navigation3.ui) implementation(libs.kotlinx.serialization.json) - - implementation("io.ktor:ktor-http:3.4.1") + implementation(libs.kotlinx.datetime) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.http) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d76b92..aa4344c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,8 @@ androidx-testExt = "1.3.0" composeMultiplatform = "1.9.0" junit = "4.13.2" kotlin = "2.2.20" +kotlinx-datetime = "0.6.2" +ktor = "3.4.1" navigation3 = "1.0.0" cmpNavigation3 = "1.0.0-alpha05" @@ -28,7 +30,13 @@ androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecyc androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "cmpNavigation3" } jetbrains-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle"} +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.9.0" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-http = { module = "io.ktor:ktor-http", version.ref = "ktor" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }