Skip to content
Draft
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,23 @@
# hiit-timer-kmp
An application creating using Kotlin Multiplatform to manage sequential timers of variable lengths used in HIIT (high intensity interval training) exercises.

---

## Design

<p align="center">
<img src="docs/design_home.png" width="280"/>
<img src="docs/design_workouts.png" width="280"/>
</p>

[View design in Figma](https://www.figma.com/design/6vZX0mpIsVlsEWiYt2Jq45/HIIT?node-id=0-1&t=Mmp4sudYLdysP95g-1)

---

## Screenshots

<p align="center">
<img src="docs/screenshot_add_interval.png" width="250"/>
<img src="docs/screenshot_name_workout.png" width="250"/>
<img src="docs/screenshot_repetitions.png" width="250"/>
</p>
1 change: 1 addition & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ kotlin {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.ktor.client.okhttp)
implementation(libs.androidx.security.crypto)
}
commonMain.dependencies {
implementation(compose.runtime)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import com.majotyler.hiittimer.data.repository.AndroidStravaTokenStorageRepository
import com.majotyler.hiittimer.presentation.common.navigation.NavigationRoot
import com.majotyler.hiittimer.presentation.platform.AndroidUrlOpener

Expand All @@ -15,18 +16,20 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)

val uri: Uri? = intent?.data
val accessToken: String? = uri?.getQueryParameter("code")
val accessCode: String? = uri?.getQueryParameter("code")

// TODO we need to use this to make a Strava Activity
println("Access token is $accessToken")
println("Strava access code: $accessCode")

val stravaTokenStorageRepository = AndroidStravaTokenStorageRepository(context = this)

setContent {
MaterialTheme {
NavigationRoot(
urlOpener = AndroidUrlOpener(context = this),
stravaAccessToken = accessToken,
stravaAccessCode = accessCode,
stravaTokenStorageRepository = stravaTokenStorageRepository,
)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.majotyler.hiittimer.data.repository

import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.majotyler.hiittimer.data.model.StoredStravaToken

class AndroidStravaTokenStorageRepository(context: Context) : StravaTokenStorageRepository {

private val prefs = EncryptedSharedPreferences.create(
context,
FILE_NAME,
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)

override suspend fun getStoredToken(): StoredStravaToken? {
val accessToken = prefs.getString(KEY_ACCESS_TOKEN, null) ?: return null
val refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null) ?: return null
val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L)
return StoredStravaToken(
accessToken = accessToken,
refreshToken = refreshToken,
expiresAt = expiresAt,
)
}

override suspend fun saveToken(accessToken: String, refreshToken: String, expiresAt: Long) {
prefs.edit()
.putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.putLong(KEY_EXPIRES_AT, expiresAt)
.apply()
println("Strava tokens saved to storage")
}

private companion object {
const val FILE_NAME = "strava_token_storage"
const val KEY_ACCESS_TOKEN = "access_token"
const val KEY_REFRESH_TOKEN = "refresh_token"
const val KEY_EXPIRES_AT = "expires_at"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 25 additions & 2 deletions composeApp/src/commonMain/composeResources/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,31 @@
<string name="add_workout_toolbar_title">Agregar entrenamiento</string>
<!--endregion Add Workout -->

<!--region Build Workouts -->
<string name="build_workouts_button_label_add_workout">[Add Workout]</string>
<string name="build_workouts_button_label_go">[Go]</string>
<string name="build_workouts_card_header_workouts">[Workouts]</string>
<!--endregion Build Workouts -->

<!--region Confirmation Dialog -->
<string name="confirmation_dialog_button_label_cancel">[Cancel]</string>
<string name="confirmation_dialog_button_label_confirm">[Confirm]</string>
<!--endregion Confirmation Dialog -->

<!--region Home -->
<string name="home_button_label_connect_with_strava">[Connect with Strava]</string>
<string name="home_button_label_launch_build_workouts">[Launch BuildWorkoutsScreen]</string>
<!--endregion Home -->

<!--region Play Workout -->
<string name="play_workout_button_label_start">[Start]</string>
<string name="play_workout_button_label_stop">[Stop]</string>
<string name="play_workout_confirmation_stop">[Are you sure you want to stop your workout?]</string>
<!--endregion Play Workout -->

<!--region Shared -->
<string name="content_description_navigate_up">Regresar</string>
<string name="content_description_remove">[Remove]</string>
<plurals name="seconds_long">
<item quantity="one">%1$d segundo</item>
<item quantity="other">%1$d segundos</item>
Expand All @@ -27,6 +50,6 @@
<string name="seconds_short_label">s</string>

<string name="interval_duration_label">Duración %1$s</string>
<string name="interval_rest_label">Descanso %s</string>
<string name="interval_rest_label">Descanso %1$s</string>
<!--endregion Shared -->
</resources>
</resources>
27 changes: 25 additions & 2 deletions composeApp/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,31 @@
<string name="add_workout_toolbar_title">Add Workout</string>
<!--endregion Add Workout -->

<!--region Build Workouts -->
<string name="build_workouts_button_label_add_workout">Add Workout</string>
<string name="build_workouts_button_label_go">Go</string>
<string name="build_workouts_card_header_workouts">Workouts</string>
<!--endregion Build Workouts -->

<!--region Confirmation Dialog -->
<string name="confirmation_dialog_button_label_cancel">Cancel</string>
<string name="confirmation_dialog_button_label_confirm">Confirm</string>
<!--endregion Confirmation Dialog -->

<!--region Home -->
<string name="home_button_label_connect_with_strava">Connect with Strava</string>
<string name="home_button_label_launch_build_workouts">Launch BuildWorkoutsScreen</string>
<!--endregion Home -->

<!--region Play Workout -->
<string name="play_workout_button_label_start">Start</string>
<string name="play_workout_button_label_stop">Stop</string>
<string name="play_workout_confirmation_stop">Are you sure you want to stop your workout?</string>
<!--endregion Play Workout -->

<!--region Shared -->
<string name="content_description_navigate_up">Go back</string>
<string name="content_description_remove">Remove</string>
<plurals name="seconds_long">
<item quantity="one">%1$d second</item>
<item quantity="other">%1$d seconds</item>
Expand All @@ -27,6 +50,6 @@
<string name="seconds_short_label">s</string>

<string name="interval_duration_label">Duration %1$s</string>
<string name="interval_rest_label">Rest %s</string>
<string name="interval_rest_label">Rest %1$s</string>
<!--endregion Shared -->
</resources>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -3,57 +3,72 @@ 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
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

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()
val response = client.post(urlString = Endpoints.OAUTH_TOKEN) {
contentType(ContentType.Application.Json)
setBody(mapOf("code" to code))
}
println("Token exchange status: ${response.status}")
return response.body()
}

suspend fun refreshAccessToken(
refreshToken: String,
): StravaAuthenticationDto {
val response = client.post(urlString = Endpoints.OAUTH_REFRESH) {
contentType(ContentType.Application.Json)
setBody(mapOf("refresh_token" to refreshToken))
}
println("Token refresh status: ${response.status}")
return response.body()
}

suspend fun createActivity(
accessToken: String,
name: String,
type: String,
description: String,
sportType: 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()
val response = client.post(urlString = Endpoints.CREATE_ACTIVITY) {
contentType(ContentType.Application.Json)
setBody(CreateActivityRequest(
accessToken = accessToken,
name = name,
description = description,
sportType = sportType,
startDateLocal = startDateLocal,
elapsedTime = elapsedTime,
))
}
println("Create activity status: ${response.status}")
}
}

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"
}
@Serializable
private data class CreateActivityRequest(
@SerialName("access_token") val accessToken: String,
@SerialName("name") val name: String,
@SerialName("description") val description: String,
@SerialName("sport_type") val sportType: String,
@SerialName("start_date_local") val startDateLocal: String,
@SerialName("elapsed_time") val elapsedTime: Int,
)

private object StravaGrantTypes {
const val AUTH_CODE = "authorization_code"
}
private object Endpoints {
const val BASE_URL = "https://strava-token-proxy.hiit-timer-app.workers.dev"
const val OAUTH_TOKEN = BASE_URL
const val OAUTH_REFRESH = "$BASE_URL/refresh"
const val CREATE_ACTIVITY = "$BASE_URL/activity"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.majotyler.hiittimer.data.model

data class StoredStravaToken(
val accessToken: String,
val refreshToken: String,
val expiresAt: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,37 @@ 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,
)
return api.getAccessToken(code = code)
}

suspend fun refreshAccessToken(
refreshToken: String,
): StravaAuthenticationDto {
return api.refreshAccessToken(refreshToken = refreshToken)
}

suspend fun createActivity(
accessToken: String,
name: String,
type: String,
description: String,
sportType: String,
startDateLocal: String,
elapsedTime: Int,
) {
return api.createActivity(accessToken, name, type, startDateLocal, elapsedTime)
api.createActivity(
accessToken = accessToken,
name = name,
description = description,
sportType = sportType,
startDateLocal = startDateLocal,
elapsedTime = elapsedTime,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.majotyler.hiittimer.data.repository

import com.majotyler.hiittimer.data.model.StoredStravaToken

interface StravaTokenStorageRepository {
suspend fun getStoredToken(): StoredStravaToken?
suspend fun saveToken(accessToken: String, refreshToken: String, expiresAt: Long)
}
Loading