Skip to content

Commit

Permalink
Merge pull request #50 from PeriodPals/feat/profile/view-model
Browse files Browse the repository at this point in the history
Feat/profile/view model
  • Loading branch information
Harrish92 authored Nov 8, 2024
2 parents a751761 + c852e89 commit b76fb41
Show file tree
Hide file tree
Showing 18 changed files with 398 additions and 14 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
runs-on: ubuntu-latest

steps:

# First step : Checkout the repository on the runner
- name: Checkout
uses: actions/checkout@v4
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# PeriodPals

Our app is an inclusive cycle tracking tool designed to **support menstruating individuals** thanks to **innovative community support tools**, along with period tracking and symptom logging.
These new community features constitute a key innovation: users can discreetly request menstrual products from nearby users through geolocation-based notifications, ensuring privacy and encouraging real-time support. This feature not only helps users in need but also fosters a supportive community, making our app a comprehensive solution for menstrual health management.
Our app is an inclusive cycle tracking tool designed to **support menstruating individuals** thanks
to **innovative community support tools**, along with period tracking and symptom logging.
These new community features constitute a key innovation: users can discreetly request menstrual
products from nearby users through geolocation-based notifications, ensuring privacy and encouraging
real-time support. This feature not only helps users in need but also fosters a supportive
community, making our app a comprehensive solution for menstrual health management.

## Links

[Figma](https://www.figma.com/team_invite/redeem/MnyBeEvw4fKH4aV5aVBpPb)
[Google Drive](https://docs.google.com/document/d/1-qGE7yrF2O_BGeR_vdvgo5ePdevHa0nPuL4w-9gv3MQ/edit?usp=sharing)
[Architecture Diagram on Excalidraw](https://excalidraw.com/#json=lnlBs2IsbkRrnxmz0vlL1,VML2k7MzPW-Jo9j7Nq6rmQ)
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ dependencies {
// Location Services
implementation("com.google.android.gms:play-services-location:21.0.1")

// mockEngine
testImplementation(libs.ktor.client.mock)

// Window Size Class
implementation("androidx.compose.material3:material3-window-size-class:1.3.0")
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:usesCleartextTraffic="false"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.PeriodPals"
android:usesCleartextTraffic="false"
tools:targetApi="31">
<activity
android:name="com.android.periodpals.MainActivity"
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/com/android/periodpals/model/user/User.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.android.periodpals.model.user

/**
* Data class representing a user.
*
* @property name The display name of the user.
* @property imageUrl The URL of the user's profile image.
* @property description A brief description of the user.
* @property dob The date of birth of the user.
*/
data class User(val name: String, val imageUrl: String, val description: String, val dob: String)
24 changes: 24 additions & 0 deletions app/src/main/java/com/android/periodpals/model/user/UserDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.android.periodpals.model.user

import kotlinx.serialization.Serializable

/**
* Data Transfer Object (DTO) for user data.
*
* @property displayName The display name of the user.
* @property imageUrl The URL of the user's profile image.
* @property description A brief description of the user.
* @property age The age of the user.
*/
@Serializable
data class UserDto(
val name: String,
val imageUrl: String,
val description: String,
val dob: String
) {
inline fun asUser(): User {
return User(
name = this.name, imageUrl = this.imageUrl, description = this.description, dob = this.dob)
}
}
22 changes: 22 additions & 0 deletions app/src/main/java/com/android/periodpals/model/user/UserModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.android.periodpals.model.user

/** Interface for user repository. Defines methods for loading and saving user profiles. */
interface UserRepository {
/**
* Loads the user profile for the given user ID.
*
* @param onSuccess callback to be called on successful call on this function returning the
* UserDto
* @param onFailure callback to be called when error is caught
*/
suspend fun loadUserProfile(onSuccess: (UserDto) -> Unit, onFailure: (Exception) -> Unit)

/**
* Creates or updates the user profile.
*
* @param user The user profile to be created or updated.
* @param onSuccess callback block to be called on success
* @param onFailure callback block to be called when exception is caught
*/
suspend fun createUserProfile(user: User, onSuccess: () -> Unit, onFailure: (Exception) -> Unit)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.android.periodpals.model.user

import android.util.Log
import io.github.jan.supabase.SupabaseClient
import io.github.jan.supabase.postgrest.postgrest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
* Implementation of UserRepository using Supabase.
*
* @property supabase The Supabase client used for making API calls.
*/
private const val TAG = "UserRepositorySupabase"
private const val USERS = "users"

class UserRepositorySupabase(private val supabase: SupabaseClient) : UserRepository {

override suspend fun loadUserProfile(
onSuccess: (UserDto) -> Unit,
onFailure: (Exception) -> Unit
) {
try {
val result =
withContext(Dispatchers.IO) {
supabase.postgrest[USERS]
.select {}
.decodeSingle<UserDto>() // RLS rules only allows user to check their own line
}
Log.d(TAG, "loadUserProfile: Success")
onSuccess(result)
} catch (e: Exception) {
Log.d(TAG, "loadUserProfile: fail to load user profile: ${e.message}")
onFailure(e)
}
}

override suspend fun createUserProfile(
user: User,
onSuccess: () -> Unit,
onFailure: (Exception) -> Unit
) {
try {
withContext(Dispatchers.IO) {
val userDto =
UserDto(
name = user.name,
imageUrl = user.imageUrl,
description = user.description,
dob = user.dob)
supabase.postgrest[USERS].insert(userDto)
}
Log.d(TAG, "createUserProfile: Success")
onSuccess()
} catch (e: java.lang.Exception) {
Log.d(TAG, "createUserProfile: fail to create user profile: ${e.message}")
onFailure(e)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.android.periodpals.model.user

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

/**
* ViewModel for managing user data.
*
* @property userRepository The repository used for loading and saving user profiles.
*/
private const val TAG = "UserViewModel"

class UserViewModel(private val userRepository: UserRepositorySupabase) : ViewModel() {

private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user

/** Loads the user profile and updates the user state. */
fun loadUser() {
viewModelScope.launch {
userRepository.loadUserProfile(
onSuccess = { userDto ->
Log.d(TAG, "loadUserProfile: Succesful")
_user.value = userDto.asUser()
},
onFailure = {
Log.d(TAG, "loadUserProfile: fail to load user profile: ${it.message}")
_user.value = null
})
}
}

/**
* Saves the user profile.
*
* @param user The user profile to be saved.
*/
fun saveUser(user: User) {
viewModelScope.launch {
userRepository.createUserProfile(
user,
onSuccess = {
Log.d(TAG, "saveUser: Success")
_user.value = user
},
onFailure = {
Log.d(TAG, "saveUser: fail to save user: ${it.message}")
_user.value = null
})
}
}
}
4 changes: 2 additions & 2 deletions app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
4 changes: 2 additions & 2 deletions app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
2 changes: 1 addition & 1 deletion app/src/main/res/values-night/themes.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<!-- Base application theme. -->
<style name="Theme.PeriodPals" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<!-- Base application theme. -->
<style name="Theme.PeriodPals" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
Expand Down
16 changes: 16 additions & 0 deletions app/src/test/java/com/android/periodpals/model/user/UserDtoTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.android.periodpals.model.user

import org.junit.Assert.assertEquals
import org.junit.Test

class UserDtoTest {

val input = UserDto("test_name", "test_url", "test_desc", "test_dob")

val output = User("test_name", "test_url", "test_desc", "test_dob")

@Test
fun asUserIsCorrect() {
assertEquals(output, input.asUser())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.android.periodpals.model.user

import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.postgrest.Postgrest
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondBadRequest
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test

class UserRepositorySupabaseTest {

private lateinit var userRepositorySupabase: UserRepositorySupabase

companion object {
val name = "test_name"
val imageUrl = "test_image"
val description = "test_description"
val dob = "test_dob"
}

private val defaultUserDto: UserDto = UserDto(name, imageUrl, description, dob)
private val defaultUser: User = User(name, imageUrl, description, dob)

private val supabaseClientSuccess =
createSupabaseClient("", "") {
httpEngine = MockEngine { _ ->
respond(
content =
"[" +
"{\"name\":\"${name}\"," +
"\"imageUrl\":\"${imageUrl}\"," +
"\"description\":\"${description}\"" +
",\"dob\":\"${dob}\"}" +
"]")
}
install(Postgrest)
}
private val supabaseClientFailure =
createSupabaseClient("", "") {
httpEngine = MockEngine { _ -> respondBadRequest() }
install(Postgrest)
}

@Before
fun setUp() {
userRepositorySupabase = mockk<UserRepositorySupabase>()
}

@Test
fun loadUserProfileIsSuccessful() {
var result: UserDto? = null

runBlocking {
val userRepositorySupabase = UserRepositorySupabase(supabaseClientSuccess)
userRepositorySupabase.loadUserProfile({ result = it }, { fail("should not call onFailure") })
assertEquals(defaultUserDto, result)
}
}

@Test
fun loadUserProfileHasFailed() {
var onFailureCalled = false

runBlocking {
val userRepositorySupabase = UserRepositorySupabase(supabaseClientFailure)
userRepositorySupabase.loadUserProfile(
{ fail("should not call onSuccess") },
{ onFailureCalled = true },
)
assert(onFailureCalled)
}
}

@Test
fun createUserProfileIsSuccessful() {
var result = false

runBlocking {
val userRepositorySupabase = UserRepositorySupabase(supabaseClientSuccess)
userRepositorySupabase.createUserProfile(
defaultUser, { result = true }, { fail("should not call onFailure") })
assert(result)
}
}

@Test
fun createUserProfileHasFailed() {
var result = false

runBlocking {
val userRepositorySupabase = UserRepositorySupabase(supabaseClientFailure)
userRepositorySupabase.createUserProfile(
defaultUser, { fail("should not call onSuccess") }, { result = true })
assert(result)
}
}
}
Loading

0 comments on commit b76fb41

Please sign in to comment.