Skip to content
Open
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
5 changes: 5 additions & 0 deletions data/build.gradle.kts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,11 @@ android {
}
}

// Room 스키마를 파일로 남겨 마이그레이션 검증에 씁니다
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}

dependencies {
implementation(projects.domain)
implementation(projects.common)
Expand Down
196 changes: 196 additions & 0 deletions data/schemas/com.project200.data.local.UndabangDatabase/1.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "953d4eadbefa6d56d23af6cd5623fb2d",
"entities": [
{
"tableName": "exercise_count",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `date` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`memberId`, `date`))",
"fields": [
{
"fieldPath": "memberId",
"columnName": "memberId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "count",
"columnName": "count",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"memberId",
"date"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "exercise_list_item",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `recordId` INTEGER NOT NULL, `date` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `title` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `thumbnailUrls` TEXT, PRIMARY KEY(`memberId`, `recordId`))",
"fields": [
{
"fieldPath": "memberId",
"columnName": "memberId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recordId",
"columnName": "recordId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "personalType",
"columnName": "personalType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrls",
"columnName": "thumbnailUrls",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"memberId",
"recordId"
]
},
"indices": [
{
"name": "index_exercise_list_item_memberId_date",
"unique": false,
"columnNames": [
"memberId",
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_list_item_memberId_date` ON `${TABLE_NAME}` (`memberId`, `date`)"
}
],
"foreignKeys": []
},
{
"tableName": "exercise_record_detail",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `recordId` INTEGER NOT NULL, `title` TEXT NOT NULL, `detail` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `location` TEXT NOT NULL, `pictures` TEXT, PRIMARY KEY(`memberId`, `recordId`))",
"fields": [
{
"fieldPath": "memberId",
"columnName": "memberId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recordId",
"columnName": "recordId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "detail",
"columnName": "detail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "personalType",
"columnName": "personalType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pictures",
"columnName": "pictures",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"memberId",
"recordId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '953d4eadbefa6d56d23af6cd5623fb2d')"
]
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
package com.project200.data.datasource

import androidx.room.withTransaction
import com.project200.data.local.PreferenceManager
import com.project200.data.local.UndabangDatabase
import com.project200.data.local.dao.ExerciseCountDao
import com.project200.data.local.dao.ExerciseRecordDao
import com.project200.data.mapper.toEntity
import com.project200.data.mapper.toModel
import com.project200.domain.model.ExerciseCount
import com.project200.domain.model.ExerciseListItem
import com.project200.domain.model.ExerciseRecord
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import timber.log.Timber
import java.time.LocalDate
import javax.inject.Inject

/**
* 운동 기록 캐시를 읽고 씁니다.
*
* 계정 조건을 여기서 한 번에 겁니다. 호출부가 memberId를 넘기지 않게 해서
* 조건을 빠뜨린 쿼리가 나올 자리를 없앱니다
*
* 로그아웃 상태에서는 읽기가 빈 값을, 쓰기가 아무 일도 하지 않습니다
*/
class ExerciseRecordLocalDataSource
@Inject
constructor(
private val database: UndabangDatabase,
private val exerciseCountDao: ExerciseCountDao,
private val exerciseRecordDao: ExerciseRecordDao,
private val preferenceManager: PreferenceManager,
) {
fun observeCountsByRange(
startDate: LocalDate,
endDate: LocalDate,
): Flow<List<ExerciseCount>> {
val memberId = currentMemberId() ?: return flowOf(emptyList())
return exerciseCountDao.observeRange(memberId, startDate, endDate)
.map { entities -> entities.map { it.toModel() } }
}

/** 서버 응답에는 운동한 날만 들어 있어서 구간을 비우고 다시 채웁니다 */
suspend fun replaceCountsByRange(
startDate: LocalDate,
endDate: LocalDate,
counts: List<ExerciseCount>,
) {
val memberId = currentMemberId() ?: return
database.withTransaction {
exerciseCountDao.deleteRange(memberId, startDate, endDate)
exerciseCountDao.upsertAll(counts.map { it.toEntity(memberId) })
}
}

fun observeListByDate(date: LocalDate): Flow<List<ExerciseListItem>> {
val memberId = currentMemberId() ?: return flowOf(emptyList())
return exerciseRecordDao.observeListByDate(memberId, date)
.map { entities -> entities.map { it.toModel() } }
}

/** 서버에서 지워진 기록이 남지 않도록 그날 목록을 통째로 갈아끼웁니다 */
suspend fun replaceListByDate(
date: LocalDate,
items: List<ExerciseListItem>,
) {
val memberId = currentMemberId() ?: return
database.withTransaction {
exerciseRecordDao.deleteListByDate(memberId, date)
exerciseRecordDao.upsertListItems(
items.mapIndexed { index, item -> item.toEntity(memberId, date, index) },
)
}
}

fun observeDetail(recordId: Long): Flow<ExerciseRecord?> {
val memberId = currentMemberId() ?: return flowOf(null)
return exerciseRecordDao.observeDetail(memberId, recordId)
.map { entity -> entity?.toModel() }
}

suspend fun saveDetail(
recordId: Long,
record: ExerciseRecord,
) {
val memberId = currentMemberId() ?: return
exerciseRecordDao.upsertDetail(record.toEntity(memberId, recordId))
}

/** 기록이 삭제되면 목록과 상세를 함께 지웁니다 */
suspend fun deleteRecord(recordId: Long) {
val memberId = currentMemberId() ?: return
database.withTransaction {
exerciseRecordDao.deleteListItem(memberId, recordId)
exerciseRecordDao.deleteDetail(memberId, recordId)
}
}

private fun currentMemberId(): String? {
return preferenceManager.getMemberId().also {
if (it == null) Timber.w("회원ID가 없어 운동 기록 캐시를 건너뜁니다")
}
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
package com.project200.data.datasource

import com.project200.data.api.ApiService
import com.project200.data.dto.BaseResponse
import com.project200.data.dto.ExerciseIdDto
import com.project200.data.dto.GetExerciseCountByRangeDTO
import com.project200.data.dto.GetExerciseRecordData
import com.project200.data.dto.GetExerciseRecordListDto
import com.project200.data.dto.PatchExerciseRequestDto
import com.project200.data.dto.PostExerciseRequestDto
import com.project200.data.dto.PostExerciseResponseDTO
import okhttp3.MultipartBody
import java.time.LocalDate
import javax.inject.Inject

/** 운동 기록의 서버 호출을 모읍니다. 응답 래핑과 오류 변환은 호출부의 apiCallBuilder가 맡습니다 */
class ExerciseRecordRemoteDataSource
@Inject
constructor(
private val apiService: ApiService,
) {
suspend fun getExerciseCountsByRange(
startDate: LocalDate,
endDate: LocalDate,
): BaseResponse<List<GetExerciseCountByRangeDTO>> = apiService.getExerciseCountsByRange(startDate, endDate)

suspend fun getExerciseRecordDetail(recordId: Long): BaseResponse<GetExerciseRecordData> =
apiService.getExerciseRecordDetail(recordId)

suspend fun getExerciseList(date: LocalDate): BaseResponse<List<GetExerciseRecordListDto>> = apiService.getExerciseList(date)

suspend fun postExerciseRecord(request: PostExerciseRequestDto): BaseResponse<PostExerciseResponseDTO> =
apiService.postExerciseRecord(request)

suspend fun patchExerciseRecord(
recordId: Long,
request: PatchExerciseRequestDto,
): BaseResponse<ExerciseIdDto> = apiService.patchExerciseRecord(recordId, request)

suspend fun postExerciseImages(
recordId: Long,
pictures: List<MultipartBody.Part>,
): BaseResponse<ExerciseIdDto> = apiService.postExerciseImages(recordId, pictures)

suspend fun deleteExerciseImages(
recordId: Long,
pictureIds: List<Long>,
): BaseResponse<Unit?> = apiService.deleteExerciseImages(recordId, pictureIds)

suspend fun deleteExerciseRecord(recordId: Long): BaseResponse<Unit?> = apiService.deleteExerciseRecord(recordId)
}
Loading
Loading