diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3dbbbf2..a6fb3e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,10 @@ dependencies { implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) + implementation(libs.coil) + implementation(libs.coil.network.okhttp) + implementation(libs.navigation.fragment.ktx) + implementation(libs.navigation.ui.ktx) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 111c250..244d0f4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,18 +4,33 @@ + + + + + + + + + @@ -33,4 +48,4 @@ android:exported="false" /> - \ No newline at end of file + diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/AnalysisService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AnalysisService.kt new file mode 100644 index 0000000..80dac51 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AnalysisService.kt @@ -0,0 +1,27 @@ +package kr.ac.anu.mumu.data.datasource + +import kr.ac.anu.mumu.data.model.AnalysisDetailDto +import kr.ac.anu.mumu.data.model.AnalysisTriggerDto +import kr.ac.anu.mumu.data.model.BaseResponse +import okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +interface AnalysisService { + @Multipart + @POST("/api/behavior-analyses") + suspend fun triggerBehaviorAnalysis( + @Query("petId") petId: Long, + @Part video: MultipartBody.Part + ): Response> + + @GET("/api/behavior-analyses/{id}") + suspend fun getBehaviorAnalysis( + @Path("id") analysisId: Long + ): Response> +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt index 8c3d418..3968755 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt @@ -7,6 +7,6 @@ import retrofit2.http.Body import retrofit2.http.POST interface AuthService { - @POST("/api/users/login") + @POST("/api/auth/login") suspend fun login(@Body request: LoginRequest): Response } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt new file mode 100644 index 0000000..b6e2926 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt @@ -0,0 +1,16 @@ +package kr.ac.anu.mumu.data.datasource + +import kr.ac.anu.mumu.data.model.BaseResponse +import kr.ac.anu.mumu.data.model.CommunityPostDto +import kr.ac.anu.mumu.data.model.PaginatedData +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Query + +interface CommunityService { + @GET("/api/community") + suspend fun getPosts( + @Query("page") page: Int = 0, + @Query("size") size: Int = 50 + ): Response>> +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/HistoryService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/HistoryService.kt new file mode 100644 index 0000000..eabbad9 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/HistoryService.kt @@ -0,0 +1,20 @@ +package kr.ac.anu.mumu.data.datasource + +import kr.ac.anu.mumu.data.model.AnalysisHistoryDto +import kr.ac.anu.mumu.data.model.BaseResponse +import kr.ac.anu.mumu.data.model.PaginatedData +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface HistoryService { + @GET("/api/pets/{petId}/analyses") + suspend fun getAnalysisHistory( + @Path("petId") petId: Long, + @Query("type") type: String = "behavior", + @Query("status") status: String = "completed", + @Query("page") page: Int = 0, + @Query("size") size: Int = 50 + ): Response>> +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/PetService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/PetService.kt new file mode 100644 index 0000000..8ac62a7 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/PetService.kt @@ -0,0 +1,11 @@ +package kr.ac.anu.mumu.data.datasource + +import kr.ac.anu.mumu.data.model.BaseResponse +import kr.ac.anu.mumu.data.model.PetDto +import retrofit2.Response +import retrofit2.http.GET + +interface PetService { + @GET("/api/pets") + suspend fun getMyPets(): Response>> +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/local/SessionManager.kt b/app/src/main/java/kr/ac/anu/mumu/data/local/SessionManager.kt new file mode 100644 index 0000000..522e1d5 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/local/SessionManager.kt @@ -0,0 +1,31 @@ +package kr.ac.anu.mumu.data.local + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SessionManager @Inject constructor( + @ApplicationContext context: Context +) { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + val accessToken: String? + get() = preferences.getString(KEY_ACCESS_TOKEN, null) + + fun saveTokens(accessToken: String, refreshToken: String, tokenType: String) { + preferences.edit() + .putString(KEY_ACCESS_TOKEN, accessToken) + .putString(KEY_REFRESH_TOKEN, refreshToken) + .putString(KEY_TOKEN_TYPE, tokenType) + .apply() + } + + private companion object { + const val PREFERENCES_NAME = "mumu_session" + const val KEY_ACCESS_TOKEN = "access_token" + const val KEY_REFRESH_TOKEN = "refresh_token" + const val KEY_TOKEN_TYPE = "token_type" + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/mapper/AnalysisHistoryDto.kt b/app/src/main/java/kr/ac/anu/mumu/data/mapper/AnalysisHistoryDto.kt new file mode 100644 index 0000000..11499ec --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/mapper/AnalysisHistoryDto.kt @@ -0,0 +1,35 @@ +package kr.ac.anu.mumu.data.mapper + +import kr.ac.anu.mumu.data.model.AnalysisHistoryDto +import kr.ac.anu.mumu.domain.model.AnalysisHistory +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.roundToInt + +fun AnalysisHistoryDto.toDomain(): AnalysisHistory { + val formattedDate = analyzedAt?.let { value -> + runCatching { + OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern("yyyy.MM.dd")) + }.getOrDefault(value.take(10).replace('-', '.')) + } ?: "-" + + val probability = confidence?.roundToInt()?.coerceIn(0, 100) ?: 0 + val isNormal = resultLabel.equals("NORMAL", ignoreCase = true) || + resultLabel == "정상" || + (suspectedItems.isNullOrEmpty() && probability < 50) + + val joinedBehavior = if (suspectedItems.isNullOrEmpty()) { + if (isNormal) "특이 행동 없음" else resultLabel ?: "분석 결과 확인" + } else { + suspectedItems.joinToString(", ") + } + + return AnalysisHistory( + id = this.analysisId, + isNormal = isNormal, + behaviorText = joinedBehavior, + probability = probability, + date = formattedDate, + resultLabel = resultLabel + ) +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/AnalysisDtos.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/AnalysisDtos.kt new file mode 100644 index 0000000..146c834 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/AnalysisDtos.kt @@ -0,0 +1,21 @@ +package kr.ac.anu.mumu.data.model + +data class AnalysisTriggerDto( + val analysisId: Long, + val status: String +) + +data class AnalysisDetailDto( + val analysisId: Long, + val type: String?, + val status: String, + val resultLabel: String?, + val suspectedItems: List?, + val confidence: Double?, + val analyzedAt: String? +) + +data class PetDto( + val petId: Long, + val name: String? +) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt index 01e8b01..7b07c19 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt @@ -1,28 +1,20 @@ package kr.ac.anu.mumu.data.model -import com.google.gson.annotations.SerializedName - data class LoginRequest( - @SerializedName("id") - val id: String, - @SerializedName("password") - val pw: String + val loginId: String, + val password: String, + val deviceType: String = "android", + val deviceInfo: String? = null ) data class LoginResponse( val success: Boolean, val message: String, - val token: String, - val user: UserDto + val data: TokenDto ) -data class UserDto( - @SerializedName("user_id") - val userId: Int, - val id: String, - val name: String, - val phone: String?, - @SerializedName("profile_image") - val profileImage: String?, - val role: String +data class TokenDto( + val accessToken: String, + val refreshToken: String, + val tokenType: String ) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt new file mode 100644 index 0000000..da47c2c --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt @@ -0,0 +1,19 @@ +package kr.ac.anu.mumu.data.model + +data class CommunityPostDto( + val communityId: Long, + val userId: Long, + val petId: Long?, + val category: String, + val title: String, + val content: String, + val hashtags: List?, + val viewCount: Int, + val likeCount: Int, + val commentCount: Int, + val bookmarkCount: Int, + val isBest: Boolean, + val createdAt: String, + val updatedAt: String, + val thumbnailUrl: String? +) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/HistoryDtos.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/HistoryDtos.kt new file mode 100644 index 0000000..6b1557b --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/HistoryDtos.kt @@ -0,0 +1,25 @@ +package kr.ac.anu.mumu.data.model + +data class BaseResponse( + val success: Boolean, + val message: String, + val data: T? +) + +data class PaginatedData( + val content: List, + val page: Int, + val size: Int, + val totalElements: Long, + val totalPages: Int +) + +data class AnalysisHistoryDto( + val analysisId: Long, + val type: String, + val status: String, + val resultLabel: String?, + val suspectedItems: List?, + val confidence: Double?, + val analyzedAt: String? +) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt index 314dbd8..865642c 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt @@ -1,29 +1,23 @@ package kr.ac.anu.mumu.data.model -import com.google.gson.annotations.SerializedName - data class JoinRequest( - val id: String, + val loginId: String, val password: String, val name: String, val phone: String, - val address: String, - @SerializedName("detail_address") val detailAddress: String, - @SerializedName("postal_code") val postalCode: String, - @SerializedName("terms_agreed") val termsAgreed: Boolean, - @SerializedName("privacy_agreed") val privacyAgreed: Boolean, - @SerializedName("marketing_agreed") val marketingAgreed: Boolean + val termsAgreed: Boolean, + val privacyAgreed: Boolean, + val marketingAgreed: Boolean ) data class JoinResponse( val success: Boolean, val message: String, - val token: String?, - val user: UserDto? + val data: UserDto? ) { data class UserDto( val userId: Int, - val id: String, + val loginId: String, val name: String, val phone: String ) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/AnalysisRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/AnalysisRepositoryImpl.kt new file mode 100644 index 0000000..52805ad --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/AnalysisRepositoryImpl.kt @@ -0,0 +1,181 @@ +package kr.ac.anu.mumu.data.repository + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kr.ac.anu.mumu.data.datasource.AnalysisService +import kr.ac.anu.mumu.data.datasource.PetService +import kr.ac.anu.mumu.data.model.AnalysisDetailDto +import kr.ac.anu.mumu.domain.model.BehaviorAnalysisResult +import kr.ac.anu.mumu.domain.repository.AnalysisRepository +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import javax.inject.Inject +import kotlin.math.roundToInt + +class AnalysisRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val analysisService: AnalysisService, + private val petService: PetService +) : AnalysisRepository { + + override suspend fun analyzeBehavior(videoUri: String): Result { + return withContext(Dispatchers.IO) { + runCatching { + val petId = getPrimaryPetId() + val uri = Uri.parse(videoUri) + val videoPart = createVideoPart(uri) + + try { + val triggerResponse = analysisService.triggerBehaviorAnalysis(petId, videoPart) + val triggerBody = triggerResponse.body() + if (!triggerResponse.isSuccessful || triggerBody?.success != true) { + error(triggerBody?.message ?: "분석 요청에 실패했습니다. (${triggerResponse.code()})") + } + val analysisId = triggerBody.data?.analysisId + ?: error("서버가 분석 ID를 반환하지 않았습니다.") + pollResult(analysisId, videoUri) + } finally { + videoPart.body.let { body -> + if (body is FileRequestBody) body.file.delete() + } + } + } + } + } + + private suspend fun getPrimaryPetId(): Long { + val response = petService.getMyPets() + val body = response.body() + if (!response.isSuccessful || body?.success != true) { + error(body?.message ?: "반려동물 정보를 불러오지 못했습니다. (${response.code()})") + } + return body.data?.firstOrNull()?.petId + ?: error("분석할 반려동물을 먼저 등록해 주세요.") + } + + private fun createVideoPart(uri: Uri): MultipartBody.Part { + val metadata = readMetadata(uri) + if (metadata.size != null && metadata.size > MAX_VIDEO_BYTES) { + error("영상은 100MB 이하만 업로드할 수 있습니다.") + } + + val extension = metadata.name.substringAfterLast('.', "mp4").lowercase() + if (extension !in SUPPORTED_EXTENSIONS) { + error("mp4, avi, mov, mkv 영상만 분석할 수 있습니다.") + } + + val temporaryFile = File.createTempFile("mumu-behavior-", ".$extension", context.cacheDir) + try { + val input = context.contentResolver.openInputStream(uri) + ?: error("선택한 영상을 열 수 없습니다.") + input.use { source -> + temporaryFile.outputStream().use(source::copyTo) + } + if (temporaryFile.length() > MAX_VIDEO_BYTES) { + error("영상은 100MB 이하만 업로드할 수 있습니다.") + } + + val mediaType = (context.contentResolver.getType(uri) ?: "video/$extension").toMediaTypeOrNull() + val requestBody = FileRequestBody(mediaType, temporaryFile) + return MultipartBody.Part.createFormData("video", metadata.name, requestBody) + } catch (error: Throwable) { + temporaryFile.delete() + throw error + } + } + + private fun readMetadata(uri: Uri): VideoMetadata { + val projection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + val name = if (nameIndex >= 0) cursor.getString(nameIndex) else null + val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else null + return VideoMetadata(name ?: "behavior-video.mp4", size) + } + } + return VideoMetadata("behavior-video.mp4", null) + } + + private suspend fun pollResult(analysisId: Long, videoUri: String): BehaviorAnalysisResult { + repeat(MAX_POLL_ATTEMPTS) { + val response = analysisService.getBehaviorAnalysis(analysisId) + val body = response.body() + if (!response.isSuccessful || body?.success != true) { + error(body?.message ?: "분석 결과를 불러오지 못했습니다. (${response.code()})") + } + + val detail = body.data ?: error("분석 결과가 비어 있습니다.") + when (detail.status.uppercase()) { + "COMPLETED" -> return detail.toResult(videoUri) + "FAILED" -> error("영상 분석에 실패했습니다. 다른 영상으로 다시 시도해 주세요.") + } + delay(POLL_INTERVAL_MILLIS) + } + error("분석 시간이 오래 걸리고 있습니다. 잠시 후 내역에서 결과를 확인해 주세요.") + } + + private fun AnalysisDetailDto.toResult(videoUri: String): BehaviorAnalysisResult { + val probability = confidence?.roundToInt()?.coerceIn(0, 100) ?: 0 + val items = suspectedItems.orEmpty().filter(String::isNotBlank) + val label = resultLabel.orEmpty() + val isNormal = label.equals("NORMAL", ignoreCase = true) || + label == "정상" || + (items.isEmpty() && probability < NORMAL_THRESHOLD) + val behavior = if (isNormal) { + "특이 행동 없음" + } else { + items.joinToString(", ").ifBlank { label.ifBlank { "이상 행동 의심" } } + } + val reason = if (isNormal) { + "분석 영상에서 뚜렷한 이상 행동이 감지되지 않았습니다." + } else { + "AI 분석에서 $behavior 패턴이 감지되었습니다." + } + val recommendation = if (isNormal) { + "현재 상태를 계속 관찰하고 새로운 이상 행동이 보이면 다시 분석해 주세요." + } else { + "같은 행동이 반복되면 가까운 동물병원에서 정밀 검사를 받아보세요." + } + + return BehaviorAnalysisResult( + videoUri = videoUri, + isNormal = isNormal, + probability = probability, + suspectedBehavior = behavior, + reason = reason, + recommendation = recommendation + ) + } + + private class FileRequestBody( + contentType: MediaType?, + val file: File + ) : RequestBody() { + private val delegate = file.asRequestBody(contentType) + + override fun contentType(): MediaType? = delegate.contentType() + override fun contentLength(): Long = delegate.contentLength() + override fun writeTo(sink: okio.BufferedSink) = delegate.writeTo(sink) + } + + private data class VideoMetadata(val name: String, val size: Long?) + + private companion object { + const val MAX_VIDEO_BYTES = 100L * 1024L * 1024L + const val MAX_POLL_ATTEMPTS = 60 + const val POLL_INTERVAL_MILLIS = 5_000L + const val NORMAL_THRESHOLD = 50 + val SUPPORTED_EXTENSIONS = setOf("mp4", "avi", "mov", "mkv") + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt new file mode 100644 index 0000000..d5a9b58 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt @@ -0,0 +1,26 @@ +package kr.ac.anu.mumu.data.repository + +import kr.ac.anu.mumu.data.datasource.CommunityService +import kr.ac.anu.mumu.domain.model.CommunityPost +import kr.ac.anu.mumu.domain.repository.CommunityRepository +import javax.inject.Inject + +class CommunityRepositoryImpl @Inject constructor( + private val communityService: CommunityService +) : CommunityRepository { + + override suspend fun getPosts(): Result> = runCatching { + val response = communityService.getPosts() + val body = response.body() + if (!response.isSuccessful || body?.success != true) { + error(body?.message ?: "게시글을 불러오지 못했습니다. (${response.code()})") + } + body.data?.content.orEmpty().map { post -> + CommunityPost( + id = post.communityId, + title = post.title, + thumbnailUrl = post.thumbnailUrl + ) + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/HistoryRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/HistoryRepositoryImpl.kt new file mode 100644 index 0000000..1714d56 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/HistoryRepositoryImpl.kt @@ -0,0 +1,55 @@ +package kr.ac.anu.mumu.data.repository + +import kr.ac.anu.mumu.data.datasource.AnalysisService +import kr.ac.anu.mumu.data.datasource.HistoryService +import kr.ac.anu.mumu.data.datasource.PetService +import kr.ac.anu.mumu.data.mapper.toDomain +import kr.ac.anu.mumu.data.model.AnalysisHistoryDto +import kr.ac.anu.mumu.domain.model.AnalysisHistory +import kr.ac.anu.mumu.domain.repository.HistoryRepository +import javax.inject.Inject + +class HistoryRepositoryImpl @Inject constructor( + private val historyService: HistoryService, + private val analysisService: AnalysisService, + private val petService: PetService +) : HistoryRepository { + + override suspend fun getBehaviorHistory(): Result> = runCatching { + val petId = getPrimaryPetId() + val response = historyService.getAnalysisHistory(petId) + val body = response.body() + if (!response.isSuccessful || body?.success != true) { + error(body?.message ?: "분석 내역을 불러오지 못했습니다. (${response.code()})") + } + body.data?.content.orEmpty().map(AnalysisHistoryDto::toDomain) + } + + override suspend fun getBehaviorDetail(analysisId: Long): Result = runCatching { + val response = analysisService.getBehaviorAnalysis(analysisId) + val body = response.body() + if (!response.isSuccessful || body?.success != true) { + error(body?.message ?: "분석 상세 정보를 불러오지 못했습니다. (${response.code()})") + } + val detail = body.data ?: error("분석 상세 정보가 비어 있습니다.") + AnalysisHistoryDto( + analysisId = detail.analysisId, + type = detail.type.orEmpty(), + status = detail.status, + resultLabel = detail.resultLabel, + suspectedItems = detail.suspectedItems, + confidence = detail.confidence, + analyzedAt = detail.analyzedAt + ).toDomain() + } + + private suspend fun getPrimaryPetId(): Long { + val response = petService.getMyPets() + val body = response.body() + if (!response.isSuccessful || body?.success != true) { + error(body?.message ?: "반려동물 정보를 불러오지 못했습니다. (${response.code()})") + } + return body.data?.firstOrNull()?.petId + ?: error("분석 내역을 확인할 반려동물을 먼저 등록해 주세요.") + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt index 4313d7d..4111bba 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt @@ -12,12 +12,12 @@ class JoinRepositoryImpl @Inject constructor( override suspend fun register(request: JoinRequest): Result { return try { val response = joinService.registerUser(request) - if (response.isSuccessful) { + if (response.isSuccessful && response.body()?.success == true) { Result.success(Unit) } else { val errorString = response.errorBody()?.string() val errorMessage = try { - JSONObject(errorString ?: "").getString("error") + JSONObject(errorString ?: "").optString("message", "회원가입에 실패했습니다.") } catch (e: Exception) { "회원가입에 실패했습니다." } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt index 27749cc..8ebbc0c 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt @@ -1,28 +1,34 @@ package kr.ac.anu.mumu.data.repository import kr.ac.anu.mumu.data.datasource.AuthService +import kr.ac.anu.mumu.data.local.SessionManager import kr.ac.anu.mumu.data.model.LoginRequest import kr.ac.anu.mumu.domain.model.User import kr.ac.anu.mumu.domain.repository.LoginRepository import javax.inject.Inject class LoginRepositoryImpl @Inject constructor( - private val authService: AuthService + private val authService: AuthService, + private val sessionManager: SessionManager ) : LoginRepository { override suspend fun login(id: String, pw: String): Result { return try { - val response = authService.login(LoginRequest(id, pw)) + val response = authService.login(LoginRequest(loginId = id, password = pw)) if (response.isSuccessful && response.body() != null) { val result = response.body()!! if (result.success) { + sessionManager.saveTokens( + accessToken = result.data.accessToken, + refreshToken = result.data.refreshToken, + tokenType = result.data.tokenType + ) val domainUser = User( - name = result.user.name, - token = result.token, - profileImage = result.user.profileImage, - role = result.user.role + accessToken = result.data.accessToken, + refreshToken = result.data.refreshToken, + tokenType = result.data.tokenType ) Result.success(domainUser) } else { diff --git a/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt b/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt index 8c979fb..17f8cb4 100644 --- a/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt +++ b/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt @@ -4,10 +4,17 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kr.ac.anu.mumu.data.datasource.AnalysisService import kr.ac.anu.mumu.data.datasource.AuthService +import kr.ac.anu.mumu.data.datasource.CommunityService +import kr.ac.anu.mumu.data.datasource.HistoryService import kr.ac.anu.mumu.data.datasource.JoinService +import kr.ac.anu.mumu.data.datasource.PetService +import kr.ac.anu.mumu.data.local.SessionManager +import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit import javax.inject.Singleton @Module @@ -15,9 +22,31 @@ import javax.inject.Singleton object NetworkModule { @Provides @Singleton - fun provideRetrofit(): Retrofit { + fun provideOkHttpClient(sessionManager: SessionManager): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor { chain -> + val token = sessionManager.accessToken + val request = if (token.isNullOrBlank()) { + chain.request() + } else { + chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + } + chain.proceed(request) + } + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(2, TimeUnit.MINUTES) + .readTimeout(2, TimeUnit.MINUTES) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { return Retrofit.Builder() - .baseUrl("https://keagan-slipperier-bewilderedly.ngrok-free.dev") // 서버 주소 + .baseUrl("http://13.125.155.32:8080/") + .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() } @@ -33,4 +62,28 @@ object NetworkModule { fun provideJoinService(retrofit: Retrofit): JoinService { return retrofit.create(JoinService::class.java) } + + @Provides + @Singleton + fun provideAnalysisService(retrofit: Retrofit): AnalysisService { + return retrofit.create(AnalysisService::class.java) + } + + @Provides + @Singleton + fun providePetService(retrofit: Retrofit): PetService { + return retrofit.create(PetService::class.java) + } + + @Provides + @Singleton + fun provideHistoryService(retrofit: Retrofit): HistoryService { + return retrofit.create(HistoryService::class.java) + } + + @Provides + @Singleton + fun provideCommunityService(retrofit: Retrofit): CommunityService { + return retrofit.create(CommunityService::class.java) + } } diff --git a/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt b/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt index 8591104..9b84967 100644 --- a/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt +++ b/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt @@ -4,8 +4,14 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kr.ac.anu.mumu.data.repository.AnalysisRepositoryImpl +import kr.ac.anu.mumu.data.repository.CommunityRepositoryImpl +import kr.ac.anu.mumu.data.repository.HistoryRepositoryImpl import kr.ac.anu.mumu.data.repository.JoinRepositoryImpl import kr.ac.anu.mumu.data.repository.LoginRepositoryImpl +import kr.ac.anu.mumu.domain.repository.AnalysisRepository +import kr.ac.anu.mumu.domain.repository.CommunityRepository +import kr.ac.anu.mumu.domain.repository.HistoryRepository import kr.ac.anu.mumu.domain.repository.JoinRepository import kr.ac.anu.mumu.domain.repository.LoginRepository import javax.inject.Singleton @@ -13,6 +19,25 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindAnalysisRepository( + analysisRepositoryImpl: AnalysisRepositoryImpl + ): AnalysisRepository + + @Binds + @Singleton + abstract fun bindHistoryRepository( + historyRepositoryImpl: HistoryRepositoryImpl + ): HistoryRepository + + @Binds + @Singleton + abstract fun bindCommunityRepository( + communityRepositoryImpl: CommunityRepositoryImpl + ): CommunityRepository + @Binds @Singleton abstract fun bindLoginRepository( diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/AnalysisHistory.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/AnalysisHistory.kt new file mode 100644 index 0000000..511f4ae --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/AnalysisHistory.kt @@ -0,0 +1,10 @@ +package kr.ac.anu.mumu.domain.model + +data class AnalysisHistory( + val id: Long, + val isNormal: Boolean, + val behaviorText: String, + val probability: Int, + val date: String, + val resultLabel: String? +) diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/BehaviorAnalysisResult.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/BehaviorAnalysisResult.kt new file mode 100644 index 0000000..62f5763 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/BehaviorAnalysisResult.kt @@ -0,0 +1,10 @@ +package kr.ac.anu.mumu.domain.model + +data class BehaviorAnalysisResult( + val videoUri: String, + val isNormal: Boolean, + val probability: Int, + val suspectedBehavior: String, + val reason: String, + val recommendation: String +) diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt new file mode 100644 index 0000000..08cb008 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt @@ -0,0 +1,7 @@ +package kr.ac.anu.mumu.domain.model + +data class CommunityPost( + val id: Long, + val title: String, + val thumbnailUrl: String? +) diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt index 8056a13..c6469c9 100644 --- a/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt +++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt @@ -1,8 +1,7 @@ package kr.ac.anu.mumu.domain.model data class User( - val name: String, - val token: String, - val profileImage: String?, - val role: String + val accessToken: String, + val refreshToken: String, + val tokenType: String ) diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/AnalysisRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/AnalysisRepository.kt new file mode 100644 index 0000000..5bc0d32 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/AnalysisRepository.kt @@ -0,0 +1,7 @@ +package kr.ac.anu.mumu.domain.repository + +import kr.ac.anu.mumu.domain.model.BehaviorAnalysisResult + +interface AnalysisRepository { + suspend fun analyzeBehavior(videoUri: String): Result +} diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt new file mode 100644 index 0000000..b5c4745 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt @@ -0,0 +1,7 @@ +package kr.ac.anu.mumu.domain.repository + +import kr.ac.anu.mumu.domain.model.CommunityPost + +interface CommunityRepository { + suspend fun getPosts(): Result> +} diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/HistoryRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/HistoryRepository.kt new file mode 100644 index 0000000..b052384 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/HistoryRepository.kt @@ -0,0 +1,8 @@ +package kr.ac.anu.mumu.domain.repository + +import kr.ac.anu.mumu.domain.model.AnalysisHistory + +interface HistoryRepository { + suspend fun getBehaviorHistory(): Result> + suspend fun getBehaviorDetail(analysisId: Long): Result +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisCaptureFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisCaptureFragment.kt new file mode 100644 index 0000000..cee8a5b --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisCaptureFragment.kt @@ -0,0 +1,113 @@ +package kr.ac.anu.mumu.presentation.analysis + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentAnalysisCaptureBinding +import java.io.File + +class AnalysisCaptureFragment : Fragment() { + + private var _binding: FragmentAnalysisCaptureBinding? = null + private val binding get() = _binding!! + private var selectedVideoUri: Uri? = null + private var pendingCaptureUri: Uri? = null + + private val captureVideo = registerForActivityResult(ActivityResultContracts.CaptureVideo()) { saved -> + if (saved) { + pendingCaptureUri?.let(::showVideo) + } else { + Toast.makeText(requireContext(), "영상 촬영이 취소되었습니다.", Toast.LENGTH_SHORT).show() + } + pendingCaptureUri = null + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAnalysisCaptureBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + savedInstanceState?.getString(STATE_SELECTED_VIDEO)?.let { showVideo(Uri.parse(it)) } + + binding.btnRecord.setOnClickListener { + val uri = createVideoUri() + pendingCaptureUri = uri + captureVideo.launch(uri) + } + binding.btnRetake.setOnClickListener { + val uri = createVideoUri() + pendingCaptureUri = uri + captureVideo.launch(uri) + } + binding.btnAnalyzeVideo.setOnClickListener { + selectedVideoUri?.let { uri -> + findNavController().navigate( + R.id.action_analysisCaptureFragment_to_analysisLoadingFragment, + bundleOf(ARG_VIDEO_URI to uri.toString()) + ) + } + } + binding.videoPreview.setOnClickListener { + if (binding.videoPreview.isPlaying) binding.videoPreview.pause() else binding.videoPreview.start() + } + } + + private fun createVideoUri(): Uri { + val directory = File(requireContext().cacheDir, "analysis-videos").apply { mkdirs() } + val file = File.createTempFile("mumu-analysis-", ".mp4", directory) + return FileProvider.getUriForFile( + requireContext(), + "${requireContext().packageName}.fileprovider", + file + ) + } + + private fun showVideo(uri: Uri) { + selectedVideoUri = uri + binding.tvCaptureGuide.visibility = View.GONE + binding.videoPreview.visibility = View.VISIBLE + binding.videoPreview.setVideoURI(uri) + binding.videoPreview.setOnPreparedListener { player -> + player.isLooping = true + binding.videoPreview.start() + } + binding.btnRecord.visibility = View.INVISIBLE + binding.btnRetake.visibility = View.VISIBLE + binding.btnAnalyzeVideo.visibility = View.VISIBLE + } + + override fun onSaveInstanceState(outState: Bundle) { + selectedVideoUri?.let { outState.putString(STATE_SELECTED_VIDEO, it.toString()) } + super.onSaveInstanceState(outState) + } + + override fun onPause() { + binding.videoPreview.pause() + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private companion object { + const val STATE_SELECTED_VIDEO = "selected_video" + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisContract.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisContract.kt new file mode 100644 index 0000000..b3a1c97 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisContract.kt @@ -0,0 +1,12 @@ +package kr.ac.anu.mumu.presentation.analysis + +import kr.ac.anu.mumu.domain.model.BehaviorAnalysisResult + +internal const val ARG_VIDEO_URI = "analysis_video_uri" + +sealed interface AnalysisUiState { + data object Idle : AnalysisUiState + data class Loading(val videoUri: String) : AnalysisUiState + data class Success(val result: BehaviorAnalysisResult) : AnalysisUiState + data class Error(val videoUri: String, val message: String) : AnalysisUiState +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisLoadingFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisLoadingFragment.kt new file mode 100644 index 0000000..7523974 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisLoadingFragment.kt @@ -0,0 +1,91 @@ +package kr.ac.anu.mumu.presentation.analysis + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentAnalysisLoadingBinding + +@AndroidEntryPoint +class AnalysisLoadingFragment : Fragment() { + + private var _binding: FragmentAnalysisLoadingBinding? = null + private val binding get() = _binding!! + private val viewModel: AnalysisViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAnalysisLoadingBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val videoUri = requireArguments().getString(ARG_VIDEO_URI).orEmpty() + require(videoUri.isNotBlank()) { "분석할 영상 URI가 필요합니다." } + viewModel.analyze(videoUri) + + binding.btnWriteDiary.setOnClickListener { + findNavController().navigate(R.id.diaryFragment) + } + binding.btnLater.setOnClickListener { + findNavController().popBackStack(R.id.homeFragment, false) + } + binding.btnRetry.setOnClickListener { + binding.btnRetry.visibility = View.GONE + binding.progressAnalysis.visibility = View.VISIBLE + binding.btnWriteDiary.visibility = View.VISIBLE + binding.btnLater.visibility = View.VISIBLE + binding.tvLoadingMessage.text = "mumu가 열심히 분석하고 있어요!..." + viewModel.analyze(videoUri) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + when (state) { + is AnalysisUiState.Loading -> { + binding.progressAnalysis.visibility = View.VISIBLE + binding.btnRetry.visibility = View.GONE + binding.btnWriteDiary.visibility = View.VISIBLE + binding.btnLater.visibility = View.VISIBLE + } + is AnalysisUiState.Success -> { + if (findNavController().currentDestination?.id == R.id.analysisLoadingFragment) { + findNavController().navigate( + R.id.action_analysisLoadingFragment_to_analysisResultFragment + ) + } + } + is AnalysisUiState.Error -> { + binding.progressAnalysis.visibility = View.GONE + binding.tvLoadingMessage.text = state.message + binding.btnRetry.visibility = View.VISIBLE + binding.btnWriteDiary.visibility = View.GONE + binding.btnLater.visibility = View.GONE + } + AnalysisUiState.Idle -> Unit + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisResultFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisResultFragment.kt new file mode 100644 index 0000000..9989df3 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisResultFragment.kt @@ -0,0 +1,76 @@ +package kr.ac.anu.mumu.presentation.analysis + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentAnalysisResultBinding +import kr.ac.anu.mumu.domain.model.BehaviorAnalysisResult + +@AndroidEntryPoint +class AnalysisResultFragment : Fragment() { + + private var _binding: FragmentAnalysisResultBinding? = null + private val binding get() = _binding!! + private val viewModel: AnalysisViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAnalysisResultBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val state = viewModel.uiState.value + if (state !is AnalysisUiState.Success) { + findNavController().popBackStack(R.id.homeFragment, false) + return + } + bindResult(state.result) + + binding.btnHome.setOnClickListener { + findNavController().popBackStack(R.id.homeFragment, false) + } + binding.videoResult.setOnClickListener { + if (binding.videoResult.isPlaying) binding.videoResult.pause() else binding.videoResult.start() + } + } + + private fun bindResult(result: BehaviorAnalysisResult) { + val status = if (result.isNormal) "정상" else "비정상" + val statusColor = if (result.isNormal) R.color.cardgreen else R.color.cardred + + binding.tvResultStatus.text = "분석결과: $status" + binding.tvResultStatus.setTextColor(ContextCompat.getColor(requireContext(), statusColor)) + binding.tvProbability.text = "${result.probability}% 확률" + binding.tvSuspectedBehavior.text = result.suspectedBehavior + binding.tvReason.text = "이유 : ${result.reason}\n\n추천 방향성 : ${result.recommendation}" + binding.videoResult.setVideoURI(Uri.parse(result.videoUri)) + binding.videoResult.setOnPreparedListener { player -> + player.isLooping = true + binding.videoResult.start() + } + } + + override fun onPause() { + binding.videoResult.pause() + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisStartFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisStartFragment.kt new file mode 100644 index 0000000..954b307 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisStartFragment.kt @@ -0,0 +1,41 @@ +package kr.ac.anu.mumu.presentation.analysis + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentAnalysisStartBinding + +class AnalysisStartFragment : Fragment() { + + private var _binding: FragmentAnalysisStartBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAnalysisStartBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.btnCapture.setOnClickListener { + findNavController().navigate(R.id.action_analysisStartFragment_to_analysisCaptureFragment) + } + binding.btnUpload.setOnClickListener { + findNavController().navigate(R.id.action_analysisStartFragment_to_analysisUploadFragment) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisUploadFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisUploadFragment.kt new file mode 100644 index 0000000..b32fb3f --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisUploadFragment.kt @@ -0,0 +1,102 @@ +package kr.ac.anu.mumu.presentation.analysis + +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import android.provider.OpenableColumns +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentAnalysisUploadBinding + +class AnalysisUploadFragment : Fragment() { + + private var _binding: FragmentAnalysisUploadBinding? = null + private val binding get() = _binding!! + private var selectedVideoUri: Uri? = null + + private val selectVideo = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let(::showVideo) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAnalysisUploadBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val restoredUri = savedInstanceState?.getString(STATE_SELECTED_VIDEO)?.let(Uri::parse) + if (restoredUri != null) showVideo(restoredUri) + + binding.btnSelectVideo.setOnClickListener { selectVideo.launch("video/*") } + binding.btnAnalyzeVideo.setOnClickListener { + selectedVideoUri?.let { uri -> + findNavController().navigate( + R.id.action_analysisUploadFragment_to_analysisLoadingFragment, + bundleOf(ARG_VIDEO_URI to uri.toString()) + ) + } + } + + if (savedInstanceState == null) selectVideo.launch("video/*") + } + + private fun showVideo(uri: Uri) { + selectedVideoUri = uri + binding.videoPreview.visibility = View.VISIBLE + binding.videoPreview.setVideoURI(uri) + binding.videoPreview.setOnPreparedListener { player -> + player.isLooping = true + binding.videoPreview.start() + } + binding.tvSelectedFile.text = getDisplayName(uri) ?: "선택한 영상" + binding.btnSelectVideo.text = "다른 영상 선택" + binding.btnAnalyzeVideo.visibility = View.VISIBLE + } + + private fun getDisplayName(uri: Uri): String? { + var cursor: Cursor? = null + return try { + cursor = requireContext().contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null + ) + if (cursor?.moveToFirst() == true) cursor.getString(0) else null + } finally { + cursor?.close() + } + } + + override fun onSaveInstanceState(outState: Bundle) { + selectedVideoUri?.let { outState.putString(STATE_SELECTED_VIDEO, it.toString()) } + super.onSaveInstanceState(outState) + } + + override fun onPause() { + binding.videoPreview.pause() + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private companion object { + const val STATE_SELECTED_VIDEO = "selected_video" + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisViewModel.kt new file mode 100644 index 0000000..5ad103b --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisViewModel.kt @@ -0,0 +1,38 @@ +package kr.ac.anu.mumu.presentation.analysis + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.domain.repository.AnalysisRepository +import javax.inject.Inject + +@HiltViewModel +class AnalysisViewModel @Inject constructor( + private val analysisRepository: AnalysisRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(AnalysisUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + fun analyze(videoUri: String) { + val currentState = _uiState.value + if (currentState is AnalysisUiState.Loading && currentState.videoUri == videoUri) return + if (currentState is AnalysisUiState.Success && currentState.result.videoUri == videoUri) return + + viewModelScope.launch { + _uiState.value = AnalysisUiState.Loading(videoUri) + analysisRepository.analyzeBehavior(videoUri) + .onSuccess { result -> _uiState.value = AnalysisUiState.Success(result) } + .onFailure { error -> + _uiState.value = AnalysisUiState.Error( + videoUri = videoUri, + message = error.message ?: "분석 중 오류가 발생했습니다." + ) + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt index 53d45db..4beb0cf 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt @@ -87,7 +87,7 @@ class AccountFragment : Fragment() { if (start != -1) { context?.let { ctx -> - val color = ContextCompat.getColor(ctx, R.color.mumumint_300) + val color = ContextCompat.getColor(ctx, R.color.mumu_300) spannable.setSpan( ForegroundColorSpan(color), diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt index afb8663..56d329b 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt @@ -65,7 +65,7 @@ class AddressFragment : Fragment() { val end = start + targetWord.length if (start != -1) { - val color = ContextCompat.getColor(requireContext(), R.color.mumumint_300) + val color = ContextCompat.getColor(requireContext(), R.color.mumu_300) spannable.setSpan( ForegroundColorSpan(color), diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt index 7cd2df2..ffc5e84 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt @@ -41,7 +41,7 @@ class FinishFragment : Fragment() { if (start != -1) { // requireContext()를 사용하여 안전하게 색상 가져오기 - val color = ContextCompat.getColor(requireContext(), R.color.mumumint_300) + val color = ContextCompat.getColor(requireContext(), R.color.mumu_300) spannable.setSpan( ForegroundColorSpan(color), diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt index 55d2428..18a4357 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt @@ -207,13 +207,10 @@ class JoinViewModel @Inject constructor( viewModelScope.launch { val request = JoinRequest( - id = inputId.value ?: "", + loginId = inputId.value ?: "", password = inputPw.value ?: "", name = inputName.value ?: "", phone = inputPhoneNum.value ?: "", - address = inputAddress.value ?: "", - detailAddress = inputDetailAddress.value ?: "", - postalCode = inputPostalCode.value ?: "", termsAgreed = isTermsAgreed.value ?: false, privacyAgreed = isPrivacyAgreed.value ?: false, marketingAgreed = isMarketingAgreed.value ?: false diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt index 5d1dd44..e46dba0 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt @@ -57,7 +57,7 @@ class LoginActivity : AppCompatActivity() { val end = start + targetWord.length if (start != -1) { - val color = ContextCompat.getColor(this, R.color.mumumint_300) + val color = ContextCompat.getColor(this, R.color.mumu_300) spannable.setSpan( ForegroundColorSpan(color), diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommFragment.kt new file mode 100644 index 0000000..e89d586 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommFragment.kt @@ -0,0 +1,30 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import kr.ac.anu.mumu.databinding.FragmentCommBinding +import kr.ac.anu.mumu.presentation.join.JoinViewModel + +class CommFragment : Fragment() { + private var _binding: FragmentCommBinding? = null + private val binding get() = _binding!! + private val viewModel: JoinViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCommBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryFragment.kt new file mode 100644 index 0000000..6d62481 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryFragment.kt @@ -0,0 +1,30 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import kr.ac.anu.mumu.databinding.FragmentDiaryBinding +import kr.ac.anu.mumu.presentation.join.JoinViewModel + +class DiaryFragment : Fragment() { + private var _binding: FragmentDiaryBinding? = null + private val binding get() = _binding!! + private val viewModel: JoinViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentDiaryBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailFragment.kt new file mode 100644 index 0000000..e142409 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailFragment.kt @@ -0,0 +1,77 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentHistoryDetailBinding +import kr.ac.anu.mumu.domain.model.AnalysisHistory + +@AndroidEntryPoint +class HistoryDetailFragment : Fragment() { + + private var _binding: FragmentHistoryDetailBinding? = null + private val binding get() = _binding!! + private val viewModel: HistoryDetailViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentHistoryDetailBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.btnRetry.setOnClickListener { viewModel.loadDetail() } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::render) + } + } + } + + private fun render(state: HistoryDetailUiState) { + binding.progressDetail.visibility = if (state is HistoryDetailUiState.Loading) View.VISIBLE else View.GONE + binding.layoutContent.visibility = if (state is HistoryDetailUiState.Success) View.VISIBLE else View.GONE + binding.layoutError.visibility = if (state is HistoryDetailUiState.Error) View.VISIBLE else View.GONE + + when (state) { + HistoryDetailUiState.Loading -> Unit + is HistoryDetailUiState.Success -> bindItem(state.item) + is HistoryDetailUiState.Error -> binding.tvError.text = state.message + } + } + + private fun bindItem(item: AnalysisHistory) { + val status = if (item.isNormal) "정상" else "비정상" + val color = if (item.isNormal) R.color.cardgreen else R.color.cardred + binding.tvResultStatus.text = "분석결과: $status" + binding.tvResultStatus.setTextColor(ContextCompat.getColor(requireContext(), color)) + binding.tvProbability.text = "${item.probability}% 확률" + binding.tvDate.text = item.date + binding.tvSuspectedBehavior.text = item.behaviorText + binding.tvReason.text = if (item.isNormal) { + "분석 영상에서 뚜렷한 이상 행동이 감지되지 않았습니다." + } else { + "AI 분석에서 ${item.behaviorText} 패턴이 감지되었습니다." + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailViewModel.kt new file mode 100644 index 0000000..cd8e93e --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailViewModel.kt @@ -0,0 +1,47 @@ +package kr.ac.anu.mumu.presentation.main + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.domain.model.AnalysisHistory +import kr.ac.anu.mumu.domain.repository.HistoryRepository +import javax.inject.Inject + +sealed interface HistoryDetailUiState { + data object Loading : HistoryDetailUiState + data class Success(val item: AnalysisHistory) : HistoryDetailUiState + data class Error(val message: String) : HistoryDetailUiState +} + +@HiltViewModel +class HistoryDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val historyRepository: HistoryRepository +) : ViewModel() { + + private val analysisId: Long = checkNotNull(savedStateHandle["analysisId"]) + private val _uiState = MutableStateFlow(HistoryDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadDetail() + } + + fun loadDetail() { + viewModelScope.launch { + _uiState.value = HistoryDetailUiState.Loading + historyRepository.getBehaviorDetail(analysisId) + .onSuccess { item -> _uiState.value = HistoryDetailUiState.Success(item) } + .onFailure { error -> + _uiState.value = HistoryDetailUiState.Error( + error.message ?: "분석 상세 정보를 불러오지 못했습니다." + ) + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryFragment.kt new file mode 100644 index 0000000..5da1ea3 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryFragment.kt @@ -0,0 +1,93 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentHistoryBinding +import kr.ac.anu.mumu.presentation.main.adapter.HistoryAdapter + +@AndroidEntryPoint +class HistoryFragment : Fragment() { + + private var _binding: FragmentHistoryBinding? = null + private val binding get() = _binding!! + + private lateinit var historyAdapter: HistoryAdapter + private val viewModel: HistoryViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentHistoryBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupRecyclerView() + observeHistory() + binding.btnRetry.setOnClickListener { viewModel.loadHistory() } + } + + private fun setupRecyclerView() { + historyAdapter = HistoryAdapter { item -> + findNavController().navigate( + R.id.analysisHistoryDetailFragment, + Bundle().apply { putLong("analysisId", item.id) } + ) + } + binding.rvHistory.apply { + adapter = historyAdapter + layoutManager = LinearLayoutManager(requireContext()) + } + } + + private fun observeHistory() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> render(state) } + } + } + } + + private fun render(state: HistoryUiState) { + binding.progressHistory.visibility = if (state is HistoryUiState.Loading) View.VISIBLE else View.GONE + binding.layoutError.visibility = if (state is HistoryUiState.Error) View.VISIBLE else View.GONE + + when (state) { + HistoryUiState.Loading -> { + binding.rvHistory.visibility = View.GONE + binding.tvEmpty.visibility = View.GONE + } + is HistoryUiState.Success -> { + historyAdapter.submitList(state.items) + binding.rvHistory.visibility = if (state.items.isEmpty()) View.GONE else View.VISIBLE + binding.tvEmpty.visibility = if (state.items.isEmpty()) View.VISIBLE else View.GONE + } + is HistoryUiState.Error -> { + binding.rvHistory.visibility = View.GONE + binding.tvEmpty.visibility = View.GONE + binding.tvError.text = state.message + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryViewModel.kt new file mode 100644 index 0000000..5d0c0e5 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryViewModel.kt @@ -0,0 +1,44 @@ +package kr.ac.anu.mumu.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.domain.model.AnalysisHistory +import kr.ac.anu.mumu.domain.repository.HistoryRepository +import javax.inject.Inject + +sealed interface HistoryUiState { + data object Loading : HistoryUiState + data class Success(val items: List) : HistoryUiState + data class Error(val message: String) : HistoryUiState +} + +@HiltViewModel +class HistoryViewModel @Inject constructor( + private val historyRepository: HistoryRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(HistoryUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadHistory() + } + + fun loadHistory() { + viewModelScope.launch { + _uiState.value = HistoryUiState.Loading + historyRepository.getBehaviorHistory() + .onSuccess { items -> _uiState.value = HistoryUiState.Success(items) } + .onFailure { error -> + _uiState.value = HistoryUiState.Error( + error.message ?: "분석 내역을 불러오지 못했습니다." + ) + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt new file mode 100644 index 0000000..03301e2 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt @@ -0,0 +1,88 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentHomeBinding + +@AndroidEntryPoint +class HomeFragment : Fragment() { + + private var _binding: FragmentHomeBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + _binding = FragmentHomeBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (savedInstanceState == null) { + setTabActive(isPeedActive = true) + replaceFragment(PostFragment()) + } + + binding.btnPeed.setOnClickListener { + setTabActive(isPeedActive = true) + replaceFragment(PostFragment()) + } + + binding.btnHistory.setOnClickListener { + setTabActive(isPeedActive = false) + replaceFragment(HistoryFragment()) + } + + binding.btnAnalyze.setOnClickListener { + findNavController().navigate(R.id.action_homeFragment_to_analysisStartFragment) + } + } + + private fun replaceFragment(fragment: Fragment) { + childFragmentManager.beginTransaction() + .replace(binding.fragmentContainer.id, fragment) + .commit() + } + + private fun setTabActive(isPeedActive: Boolean) { + val activeColor = ContextCompat.getColor(requireContext(), R.color.mumu_300) + val inactiveColor = ContextCompat.getColor(requireContext(), R.color.mumugray_150) + + val peedIcon = binding.btnPeed.getChildAt(0) as ImageView + val peedText = binding.btnPeed.getChildAt(1) as TextView + val historyIcon = binding.btnHistory.getChildAt(0) as ImageView + val historyText = binding.btnHistory.getChildAt(1) as TextView + + if (isPeedActive) { + peedIcon.setColorFilter(activeColor) + peedText.setTextColor(activeColor) + + historyIcon.setColorFilter(inactiveColor) + historyText.setTextColor(inactiveColor) + } else { + peedIcon.setColorFilter(inactiveColor) + peedText.setTextColor(inactiveColor) + + historyIcon.setColorFilter(activeColor) + historyText.setTextColor(activeColor) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalFragment.kt new file mode 100644 index 0000000..966a939 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalFragment.kt @@ -0,0 +1,30 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import dagger.hilt.android.AndroidEntryPoint +import kr.ac.anu.mumu.databinding.FragmentHospitalBinding + +@AndroidEntryPoint +class HospitalFragment : Fragment() { + + private var _binding: FragmentHospitalBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + _binding = FragmentHospitalBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt index 7901e6c..ccccbb1 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt @@ -1,21 +1,64 @@ package kr.ac.anu.mumu.presentation.main import android.os.Bundle +import android.view.View import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController +import dagger.hilt.android.AndroidEntryPoint import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.ActivityMainBinding +@AndroidEntryPoint class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContentView(R.layout.activity_main) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) insets } + + val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment_main) as NavHostFragment + val navController = navHostFragment.navController + + binding.bottomNav.setupWithNavController(navController) + + val analysisDestinations = setOf( + R.id.analysisStartFragment, + R.id.analysisCaptureFragment, + R.id.analysisUploadFragment, + R.id.analysisLoadingFragment, + R.id.analysisResultFragment, + R.id.analysisHistoryDetailFragment + ) + + binding.btnBack.setOnClickListener { + navController.navigateUp() + } + + navController.addOnDestinationChangedListener { _, destination, _ -> + val isAnalysisDestination = destination.id in analysisDestinations + binding.btnBack.visibility = if (isAnalysisDestination) View.VISIBLE else View.GONE + binding.ivLogo.visibility = if (isAnalysisDestination) View.GONE else View.VISIBLE + binding.tvMumu.visibility = if (isAnalysisDestination) View.GONE else View.VISIBLE + binding.tvAnalysisMumu.visibility = if (isAnalysisDestination) View.VISIBLE else View.GONE + binding.bottomNav.visibility = if (isAnalysisDestination) View.GONE else View.VISIBLE + } + + ViewCompat.setOnApplyWindowInsetsListener(binding.bottomNav) { view, windowInsets -> + view.updatePadding(bottom = 0) + windowInsets + } } } diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainViewModel.kt new file mode 100644 index 0000000..d18439d --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainViewModel.kt @@ -0,0 +1,8 @@ +package kr.ac.anu.mumu.presentation.main + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor() : ViewModel() diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyFragment.kt new file mode 100644 index 0000000..20ec90d --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyFragment.kt @@ -0,0 +1,30 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import dagger.hilt.android.AndroidEntryPoint +import kr.ac.anu.mumu.databinding.FragmentMyBinding + +@AndroidEntryPoint +class MyFragment : Fragment() { + + private var _binding: FragmentMyBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + _binding = FragmentMyBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt new file mode 100644 index 0000000..89733b7 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt @@ -0,0 +1,79 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.GridLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.databinding.FragmentPostBinding +import kr.ac.anu.mumu.presentation.main.adapter.PostAdapter +import kr.ac.anu.mumu.presentation.main.adapter.PostGridSpacingDecoration + +@AndroidEntryPoint +class PostFragment : Fragment() { + + private var _binding: FragmentPostBinding? = null + private val binding get() = _binding!! + private val viewModel: PostViewModel by viewModels() + private val postAdapter = PostAdapter() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentPostBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.rvPosts.apply { + adapter = postAdapter + layoutManager = GridLayoutManager(requireContext(), 3) + addItemDecoration(PostGridSpacingDecoration()) + } + binding.btnRetry.setOnClickListener { viewModel.loadPosts() } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::render) + } + } + } + + private fun render(state: PostUiState) { + binding.progressPosts.visibility = if (state is PostUiState.Loading) View.VISIBLE else View.GONE + binding.layoutError.visibility = if (state is PostUiState.Error) View.VISIBLE else View.GONE + + when (state) { + PostUiState.Loading -> { + binding.rvPosts.visibility = View.GONE + binding.tvEmpty.visibility = View.GONE + } + is PostUiState.Success -> { + postAdapter.submitList(state.posts) + binding.rvPosts.visibility = if (state.posts.isEmpty()) View.GONE else View.VISIBLE + binding.tvEmpty.visibility = if (state.posts.isEmpty()) View.VISIBLE else View.GONE + } + is PostUiState.Error -> { + binding.rvPosts.visibility = View.GONE + binding.tvEmpty.visibility = View.GONE + binding.tvError.text = state.message + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostViewModel.kt new file mode 100644 index 0000000..379c6fe --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostViewModel.kt @@ -0,0 +1,44 @@ +package kr.ac.anu.mumu.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.domain.model.CommunityPost +import kr.ac.anu.mumu.domain.repository.CommunityRepository +import javax.inject.Inject + +sealed interface PostUiState { + data object Loading : PostUiState + data class Success(val posts: List) : PostUiState + data class Error(val message: String) : PostUiState +} + +@HiltViewModel +class PostViewModel @Inject constructor( + private val communityRepository: CommunityRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(PostUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadPosts() + } + + fun loadPosts() { + viewModelScope.launch { + _uiState.value = PostUiState.Loading + communityRepository.getPosts() + .onSuccess { posts -> _uiState.value = PostUiState.Success(posts) } + .onFailure { error -> + _uiState.value = PostUiState.Error( + error.message ?: "게시글을 불러오지 못했습니다." + ) + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/HistoryAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/HistoryAdapter.kt new file mode 100644 index 0000000..4b35e4f --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/HistoryAdapter.kt @@ -0,0 +1,61 @@ +package kr.ac.anu.mumu.presentation.main.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.ItemHistoryBinding +import kr.ac.anu.mumu.domain.model.AnalysisHistory + +class HistoryAdapter( + private val onClick: (AnalysisHistory) -> Unit +) : ListAdapter(HistoryDiffCallback) { + + inner class HistoryViewHolder(private val binding: ItemHistoryBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: AnalysisHistory) { + val context = binding.root.context + + binding.tvDate.text = item.date + binding.tvProbability.text = "확률 : ${item.probability}%" + + if (item.isNormal) { + val greenColor = ContextCompat.getColor(context, R.color.cardgreen) + binding.cardResult.strokeColor = greenColor + binding.tvResultStatus.text = "검사결과 : 정상" + binding.tvDescription.text = "검사목록 : ${item.behaviorText}" + } else { + val redColor = ContextCompat.getColor(context, R.color.cardred) + binding.cardResult.strokeColor = redColor + binding.tvResultStatus.text = "검사결과 : 비정상" + binding.tvDescription.text = "의심행동 : ${item.behaviorText}" + } + binding.root.setOnClickListener { onClick(item) } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryViewHolder { + val binding = ItemHistoryBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return HistoryViewHolder(binding) + } + + override fun onBindViewHolder(holder: HistoryViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + private object HistoryDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AnalysisHistory, newItem: AnalysisHistory): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: AnalysisHistory, newItem: AnalysisHistory): Boolean = + oldItem == newItem + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt new file mode 100644 index 0000000..e4245aa --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt @@ -0,0 +1,51 @@ +package kr.ac.anu.mumu.presentation.main.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil3.load +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.ItemPostBinding +import kr.ac.anu.mumu.domain.model.CommunityPost + +class PostAdapter : ListAdapter(PostDiffCallback) { + + class PostViewHolder( + private val binding: ItemPostBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(post: CommunityPost) { + binding.ivPostImage.contentDescription = post.title + if (post.thumbnailUrl.isNullOrBlank()) { + binding.ivPostImage.setImageResource(R.drawable.ic_diary) + } else { + binding.ivPostImage.load(post.thumbnailUrl) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder { + val binding = ItemPostBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return PostViewHolder(binding) + } + + override fun onBindViewHolder(holder: PostViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + private object PostDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CommunityPost, newItem: CommunityPost): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: CommunityPost, newItem: CommunityPost): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostGridSpacingDecoration.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostGridSpacingDecoration.kt new file mode 100644 index 0000000..c00203c --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostGridSpacingDecoration.kt @@ -0,0 +1,36 @@ +package kr.ac.anu.mumu.presentation.main.adapter + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class PostGridSpacingDecoration : RecyclerView.ItemDecoration() { + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildAdapterPosition(view) + if (position == RecyclerView.NO_POSITION) return + + when (position % SPAN_COUNT) { + 0 -> outRect.set(9.dp(view), 0, 2.dp(view), rowTop(position, view)) + 1 -> outRect.set(6.dp(view), 0, 5.dp(view), rowTop(position, view)) + 2 -> outRect.set(2.dp(view), 0, 9.dp(view), rowTop(position, view)) + } + } + + private fun rowTop(position: Int, view: View): Int { + return if (position < SPAN_COUNT) 0 else 8.dp(view) + } + + private fun Int.dp(view: View): Int { + return (this * view.resources.displayMetrics.density).toInt() + } + + private companion object { + const val SPAN_COUNT = 3 + } +} diff --git a/app/src/main/res/color/selector_bottom_nav.xml b/app/src/main/res/color/selector_bottom_nav.xml new file mode 100644 index 0000000..084c2c3 --- /dev/null +++ b/app/src/main/res/color/selector_bottom_nav.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_analysis_circle_outline.xml b/app/src/main/res/drawable/bg_analysis_circle_outline.xml new file mode 100644 index 0000000..70bf13d --- /dev/null +++ b/app/src/main/res/drawable/bg_analysis_circle_outline.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_analysis_circle_primary.xml b/app/src/main/res/drawable/bg_analysis_circle_primary.xml new file mode 100644 index 0000000..b9d67db --- /dev/null +++ b/app/src/main/res/drawable/bg_analysis_circle_primary.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/bg_analysis_header.xml b/app/src/main/res/drawable/bg_analysis_header.xml new file mode 100644 index 0000000..683a1e1 --- /dev/null +++ b/app/src/main/res/drawable/bg_analysis_header.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_analysis_item.xml b/app/src/main/res/drawable/bg_analysis_item.xml new file mode 100644 index 0000000..6e712c5 --- /dev/null +++ b/app/src/main/res/drawable/bg_analysis_item.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_analysis_panel.xml b/app/src/main/res/drawable/bg_analysis_panel.xml new file mode 100644 index 0000000..4fde999 --- /dev/null +++ b/app/src/main/res/drawable/bg_analysis_panel.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_analysis_primary.xml b/app/src/main/res/drawable/bg_analysis_primary.xml new file mode 100644 index 0000000..3b2feb8 --- /dev/null +++ b/app/src/main/res/drawable/bg_analysis_primary.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_analysis_secondary.xml b/app/src/main/res/drawable/bg_analysis_secondary.xml new file mode 100644 index 0000000..1f15749 --- /dev/null +++ b/app/src/main/res/drawable/bg_analysis_secondary.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_analysis_video.xml b/app/src/main/res/drawable/bg_analysis_video.xml new file mode 100644 index 0000000..36dbc16 --- /dev/null +++ b/app/src/main/res/drawable/bg_analysis_video.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_btn_analyze.xml b/app/src/main/res/drawable/bg_btn_analyze.xml new file mode 100644 index 0000000..67b296e --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_analyze.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_btn_write.xml b/app/src/main/res/drawable/bg_btn_write.xml new file mode 100644 index 0000000..9492222 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_write.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_pill_yellow.xml b/app/src/main/res/drawable/bg_pill_yellow.xml new file mode 100644 index 0000000..89f259b --- /dev/null +++ b/app/src/main/res/drawable/bg_pill_yellow.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_post_box.xml b/app/src/main/res/drawable/bg_post_box.xml new file mode 100644 index 0000000..15ff3d9 --- /dev/null +++ b/app/src/main/res/drawable/bg_post_box.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/btn_disabled.xml b/app/src/main/res/drawable/btn_disabled.xml index 375755b..3333edc 100644 --- a/app/src/main/res/drawable/btn_disabled.xml +++ b/app/src/main/res/drawable/btn_disabled.xml @@ -3,6 +3,6 @@ android:shape="rectangle"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/btn_enabled.xml b/app/src/main/res/drawable/btn_enabled.xml index 3fce4ed..cb29bec 100644 --- a/app/src/main/res/drawable/btn_enabled.xml +++ b/app/src/main/res/drawable/btn_enabled.xml @@ -3,6 +3,6 @@ android:shape="rectangle"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/btn_join_background.xml b/app/src/main/res/drawable/btn_join_background.xml index ab4e216..1fe1b95 100644 --- a/app/src/main/res/drawable/btn_join_background.xml +++ b/app/src/main/res/drawable/btn_join_background.xml @@ -5,6 +5,6 @@ + android:color="@color/mumu_150" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/dog.jpg b/app/src/main/res/drawable/dog.jpg new file mode 100644 index 0000000..455c843 Binary files /dev/null and b/app/src/main/res/drawable/dog.jpg differ diff --git a/app/src/main/res/drawable/dog_foot.png b/app/src/main/res/drawable/dog_foot.png new file mode 100644 index 0000000..465d287 Binary files /dev/null and b/app/src/main/res/drawable/dog_foot.png differ diff --git a/app/src/main/res/drawable/ic_diary.xml b/app/src/main/res/drawable/ic_diary.xml new file mode 100644 index 0000000..a90c972 --- /dev/null +++ b/app/src/main/res/drawable/ic_diary.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_empty_heart.png b/app/src/main/res/drawable/ic_empty_heart.png new file mode 100644 index 0000000..84a0c10 Binary files /dev/null and b/app/src/main/res/drawable/ic_empty_heart.png differ diff --git a/app/src/main/res/drawable/ic_foot.png b/app/src/main/res/drawable/ic_foot.png new file mode 100644 index 0000000..2a38116 Binary files /dev/null and b/app/src/main/res/drawable/ic_foot.png differ diff --git a/app/src/main/res/drawable/ic_heart.png b/app/src/main/res/drawable/ic_heart.png new file mode 100644 index 0000000..387a8c0 Binary files /dev/null and b/app/src/main/res/drawable/ic_heart.png differ diff --git a/app/src/main/res/drawable/ic_history.png b/app/src/main/res/drawable/ic_history.png new file mode 100644 index 0000000..ed9d701 Binary files /dev/null and b/app/src/main/res/drawable/ic_history.png differ diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..3ac74ad --- /dev/null +++ b/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_hospital.xml b/app/src/main/res/drawable/ic_hospital.xml new file mode 100644 index 0000000..d1bd8ec --- /dev/null +++ b/app/src/main/res/drawable/ic_hospital.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_man.png b/app/src/main/res/drawable/ic_man.png new file mode 100644 index 0000000..a753cc8 Binary files /dev/null and b/app/src/main/res/drawable/ic_man.png differ diff --git a/app/src/main/res/drawable/ic_message.xml b/app/src/main/res/drawable/ic_message.xml new file mode 100644 index 0000000..26107c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_message.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_user.xml b/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 0000000..492d428 --- /dev/null +++ b/app/src/main/res/drawable/ic_user.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_woman.png b/app/src/main/res/drawable/ic_woman.png new file mode 100644 index 0000000..bb66c25 Binary files /dev/null and b/app/src/main/res/drawable/ic_woman.png differ diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 779ee7a..56f4247 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -94,7 +94,7 @@ android:background="@drawable/btn_join_background" android:fontFamily="@font/pretendard_semibold" android:text="회원가입" - android:textColor="@color/mumumint_250" + android:textColor="@color/mumu_250" android:textSize="@dimen/body_4" app:layout_constraintEnd_toEndOf="@id/btn_login" app:layout_constraintStart_toStartOf="@id/btn_login" diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a75041f..6c7d021 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -5,15 +5,99 @@ android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@color/white" tools:context=".presentation.main.MainActivity"> - + + + + + + + + + + + + + + + app:menu="@menu/main_menu" + app:paddingBottomSystemWindowInsets="false" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_analysis_capture.xml b/app/src/main/res/layout/fragment_analysis_capture.xml new file mode 100644 index 0000000..83eaf13 --- /dev/null +++ b/app/src/main/res/layout/fragment_analysis_capture.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_analysis_loading.xml b/app/src/main/res/layout/fragment_analysis_loading.xml new file mode 100644 index 0000000..b357af5 --- /dev/null +++ b/app/src/main/res/layout/fragment_analysis_loading.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_analysis_result.xml b/app/src/main/res/layout/fragment_analysis_result.xml new file mode 100644 index 0000000..d934149 --- /dev/null +++ b/app/src/main/res/layout/fragment_analysis_result.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_analysis_start.xml b/app/src/main/res/layout/fragment_analysis_start.xml new file mode 100644 index 0000000..ad484b3 --- /dev/null +++ b/app/src/main/res/layout/fragment_analysis_start.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_analysis_upload.xml b/app/src/main/res/layout/fragment_analysis_upload.xml new file mode 100644 index 0000000..04c7f0c --- /dev/null +++ b/app/src/main/res/layout/fragment_analysis_upload.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_comm.xml b/app/src/main/res/layout/fragment_comm.xml new file mode 100644 index 0000000..3728533 --- /dev/null +++ b/app/src/main/res/layout/fragment_comm.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_diary.xml b/app/src/main/res/layout/fragment_diary.xml new file mode 100644 index 0000000..9c463cc --- /dev/null +++ b/app/src/main/res/layout/fragment_diary.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_history.xml b/app/src/main/res/layout/fragment_history.xml new file mode 100644 index 0000000..8d767e9 --- /dev/null +++ b/app/src/main/res/layout/fragment_history.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_history_detail.xml b/app/src/main/res/layout/fragment_history_detail.xml new file mode 100644 index 0000000..8a6d17b --- /dev/null +++ b/app/src/main/res/layout/fragment_history_detail.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml new file mode 100644 index 0000000..b1a3048 --- /dev/null +++ b/app/src/main/res/layout/fragment_home.xml @@ -0,0 +1,302 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_hospital.xml b/app/src/main/res/layout/fragment_hospital.xml new file mode 100644 index 0000000..77d9ef6 --- /dev/null +++ b/app/src/main/res/layout/fragment_hospital.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_my.xml b/app/src/main/res/layout/fragment_my.xml new file mode 100644 index 0000000..77d9ef6 --- /dev/null +++ b/app/src/main/res/layout/fragment_my.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_post.xml b/app/src/main/res/layout/fragment_post.xml new file mode 100644 index 0000000..ed1aec2 --- /dev/null +++ b/app/src/main/res/layout/fragment_post.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_terms.xml b/app/src/main/res/layout/fragment_terms.xml index 497a385..8c2cb0e 100644 --- a/app/src/main/res/layout/fragment_terms.xml +++ b/app/src/main/res/layout/fragment_terms.xml @@ -44,7 +44,7 @@ android:text="전체 동의하기" android:textColor="@color/mumublack" android:textSize="@dimen/body_4" - app:buttonTint="@color/mumumint_300" app:layout_constraintStart_toStartOf="parent" + app:buttonTint="@color/mumu_300" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -99,7 +99,7 @@ android:text="개인정보 취급방침 (필수)" android:textColor="@color/mumugray_400" android:textSize="@dimen/body_4" - app:buttonTint="@color/mumumint_300" + app:buttonTint="@color/mumu_300" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/cb_terms" /> @@ -133,7 +133,7 @@ android:text="마케팅 수신 동의 (선택)" android:textColor="@color/mumugray_400" android:textSize="@dimen/body_4" - app:buttonTint="@color/mumumint_300" + app:buttonTint="@color/mumu_300" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/cb_privacy" /> diff --git a/app/src/main/res/layout/item_history.xml b/app/src/main/res/layout/item_history.xml new file mode 100644 index 0000000..2d334cf --- /dev/null +++ b/app/src/main/res/layout/item_history.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_post.xml b/app/src/main/res/layout/item_post.xml new file mode 100644 index 0000000..5ad9450 --- /dev/null +++ b/app/src/main/res/layout/item_post.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml new file mode 100644 index 0000000..99cd1fc --- /dev/null +++ b/app/src/main/res/menu/main_menu.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph_main.xml b/app/src/main/res/navigation/nav_graph_main.xml new file mode 100644 index 0000000..9b560f2 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph_main.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 88c8551..296f994 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -3,12 +3,12 @@ #262626 #FFFFFF - #80CBC4 - #97D3CE - #AFDCD8 - #C6E4E1 - #DEEDEB - #E9F0F0 + #FFC13B + #FDCB60 + #FBD685 + #F9E0AB + #F7EBD0 + #F6EFE2 #F5F5F5 #E9E9EA @@ -21,4 +21,7 @@ #F44355 #4B88FF + + #DF7171 + #88BA47 \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 328ba4a..9bc8422 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -6,4 +6,18 @@ + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..dae037f --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ec3a43..effce90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,8 +14,11 @@ lifecycleViewmodelKtx = "2.10.0" material = "1.13.0" activity = "1.12.1" constraintlayout = "2.2.1" +coil = "3.2.0" navigationFragmentKtx = "2.9.7" retrofit2 = "2.9.0" +navigationFragmentKtxVersion = "2.9.8" +navigationUiKtx = "2.9.8" [libraries] androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" } @@ -36,7 +39,11 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } retrofit2 = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit2" } +navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtxVersion" } +navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }