diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4a8e59719..a31db4508 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -17,6 +17,9 @@
android:theme="@style/Theme.Turip"
android:usesCleartextTraffic="true"
tools:targetApi="31">
+
diff --git a/android/app/src/main/java/com/on/turip/data/account/AccountMapper.kt b/android/app/src/main/java/com/on/turip/data/account/AccountMapper.kt
new file mode 100644
index 000000000..c0ad6e7cf
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/data/account/AccountMapper.kt
@@ -0,0 +1,12 @@
+package com.on.turip.data.account
+
+import com.on.turip.data.account.dto.MyProfileResponse
+import com.on.turip.domain.account.Account
+import com.on.turip.domain.account.Role
+
+fun MyProfileResponse.toDomain(): Account =
+ Account(
+ id = id,
+ nickname = nickname,
+ role = Role.from(role),
+ )
diff --git a/android/app/src/main/java/com/on/turip/data/accounts/datasource/AccountRemoteDataSource.kt b/android/app/src/main/java/com/on/turip/data/account/datasource/AccountRemoteDataSource.kt
similarity index 59%
rename from android/app/src/main/java/com/on/turip/data/accounts/datasource/AccountRemoteDataSource.kt
rename to android/app/src/main/java/com/on/turip/data/account/datasource/AccountRemoteDataSource.kt
index 86e924419..834cc77dd 100644
--- a/android/app/src/main/java/com/on/turip/data/accounts/datasource/AccountRemoteDataSource.kt
+++ b/android/app/src/main/java/com/on/turip/data/account/datasource/AccountRemoteDataSource.kt
@@ -1,7 +1,7 @@
-package com.on.turip.data.accounts.datasource
+package com.on.turip.data.account.datasource
import com.on.turip.core.result.TuripResult
-import com.on.turip.data.accounts.dto.MyProfileResponse
+import com.on.turip.data.account.dto.MyProfileResponse
interface AccountRemoteDataSource {
suspend fun getMyProfile(): TuripResult
diff --git a/android/app/src/main/java/com/on/turip/data/accounts/datasource/DefaultAccountRemoteDataSource.kt b/android/app/src/main/java/com/on/turip/data/account/datasource/DefaultAccountRemoteDataSource.kt
similarity index 70%
rename from android/app/src/main/java/com/on/turip/data/accounts/datasource/DefaultAccountRemoteDataSource.kt
rename to android/app/src/main/java/com/on/turip/data/account/datasource/DefaultAccountRemoteDataSource.kt
index ce4675f27..2e316309e 100644
--- a/android/app/src/main/java/com/on/turip/data/accounts/datasource/DefaultAccountRemoteDataSource.kt
+++ b/android/app/src/main/java/com/on/turip/data/account/datasource/DefaultAccountRemoteDataSource.kt
@@ -1,8 +1,8 @@
-package com.on.turip.data.accounts.datasource
+package com.on.turip.data.account.datasource
import com.on.turip.core.result.TuripResult
-import com.on.turip.data.accounts.dto.MyProfileResponse
-import com.on.turip.data.accounts.service.AccountService
+import com.on.turip.data.account.dto.MyProfileResponse
+import com.on.turip.data.account.service.AccountService
import com.on.turip.data.result.safeApiCall
import javax.inject.Inject
diff --git a/android/app/src/main/java/com/on/turip/data/accounts/dto/MyProfileResponse.kt b/android/app/src/main/java/com/on/turip/data/account/dto/MyProfileResponse.kt
similarity index 87%
rename from android/app/src/main/java/com/on/turip/data/accounts/dto/MyProfileResponse.kt
rename to android/app/src/main/java/com/on/turip/data/account/dto/MyProfileResponse.kt
index 6d72d5375..5dd0dc242 100644
--- a/android/app/src/main/java/com/on/turip/data/accounts/dto/MyProfileResponse.kt
+++ b/android/app/src/main/java/com/on/turip/data/account/dto/MyProfileResponse.kt
@@ -1,4 +1,4 @@
-package com.on.turip.data.accounts.dto
+package com.on.turip.data.account.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
diff --git a/android/app/src/main/java/com/on/turip/data/accounts/repository/DefaultAccountRepository.kt b/android/app/src/main/java/com/on/turip/data/account/repository/DefaultAccountRepository.kt
similarity index 60%
rename from android/app/src/main/java/com/on/turip/data/accounts/repository/DefaultAccountRepository.kt
rename to android/app/src/main/java/com/on/turip/data/account/repository/DefaultAccountRepository.kt
index 7001dfd19..e72edd64d 100644
--- a/android/app/src/main/java/com/on/turip/data/accounts/repository/DefaultAccountRepository.kt
+++ b/android/app/src/main/java/com/on/turip/data/account/repository/DefaultAccountRepository.kt
@@ -1,11 +1,11 @@
-package com.on.turip.data.accounts.repository
+package com.on.turip.data.account.repository
import com.on.turip.core.result.TuripResult
import com.on.turip.core.result.mapCatching
-import com.on.turip.data.accounts.datasource.AccountRemoteDataSource
-import com.on.turip.data.accounts.toDomain
-import com.on.turip.domain.accounts.Account
-import com.on.turip.domain.accounts.AccountRepository
+import com.on.turip.data.account.datasource.AccountRemoteDataSource
+import com.on.turip.data.account.toDomain
+import com.on.turip.domain.account.Account
+import com.on.turip.domain.account.AccountRepository
import javax.inject.Inject
class DefaultAccountRepository @Inject constructor(
diff --git a/android/app/src/main/java/com/on/turip/data/accounts/service/AccountService.kt b/android/app/src/main/java/com/on/turip/data/account/service/AccountService.kt
similarity index 59%
rename from android/app/src/main/java/com/on/turip/data/accounts/service/AccountService.kt
rename to android/app/src/main/java/com/on/turip/data/account/service/AccountService.kt
index 07dc89db9..1e3d70819 100644
--- a/android/app/src/main/java/com/on/turip/data/accounts/service/AccountService.kt
+++ b/android/app/src/main/java/com/on/turip/data/account/service/AccountService.kt
@@ -1,6 +1,6 @@
-package com.on.turip.data.accounts.service
+package com.on.turip.data.account.service
-import com.on.turip.data.accounts.dto.MyProfileResponse
+import com.on.turip.data.account.dto.MyProfileResponse
import de.jensklingenberg.ktorfit.http.GET
interface AccountService {
diff --git a/android/app/src/main/java/com/on/turip/data/accounts/AccountMapper.kt b/android/app/src/main/java/com/on/turip/data/accounts/AccountMapper.kt
deleted file mode 100644
index 6c4c6fec2..000000000
--- a/android/app/src/main/java/com/on/turip/data/accounts/AccountMapper.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.on.turip.data.accounts
-
-import com.on.turip.data.accounts.dto.MyProfileResponse
-import com.on.turip.domain.accounts.Account
-import com.on.turip.domain.accounts.Role
-
-fun MyProfileResponse.toDomain(): Account =
- Account(
- id = id,
- nickname = nickname,
- role = Role.from(role),
- )
diff --git a/android/app/src/main/java/com/on/turip/data/bookmark/BookmarkMapper.kt b/android/app/src/main/java/com/on/turip/data/bookmark/BookmarkMapper.kt
new file mode 100644
index 000000000..31f0d7eae
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/data/bookmark/BookmarkMapper.kt
@@ -0,0 +1,23 @@
+package com.on.turip.data.bookmark
+
+import com.on.turip.data.bookmark.dto.BookmarkAddRequest
+import com.on.turip.data.bookmark.dto.BookmarkContentResponse
+import com.on.turip.data.bookmark.dto.BookmarkContentsResponse
+import com.on.turip.data.content.toDomain
+import com.on.turip.domain.bookmark.BookmarkContent
+import com.on.turip.domain.common.paging.Page
+
+fun Long.toRequestDto(): BookmarkAddRequest = BookmarkAddRequest(contentId = this)
+
+fun BookmarkContentsResponse.toDomain(): Page =
+ Page(
+ items = contents.map { it.toDomain() },
+ hasNext = loadable,
+ )
+
+fun BookmarkContentResponse.toDomain(): BookmarkContent =
+ BookmarkContent(
+ content = content.toDomain(),
+ tripDuration = tripDuration.toDomain(),
+ tripPlaceCount = tripPlaceCount,
+ )
diff --git a/android/app/src/main/java/com/on/turip/data/bookmark/datasource/BookmarkRemoteDataSource.kt b/android/app/src/main/java/com/on/turip/data/bookmark/datasource/BookmarkRemoteDataSource.kt
new file mode 100644
index 000000000..a62f7af36
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/data/bookmark/datasource/BookmarkRemoteDataSource.kt
@@ -0,0 +1,17 @@
+package com.on.turip.data.bookmark.datasource
+
+import com.on.turip.core.result.TuripResult
+import com.on.turip.data.bookmark.dto.BookmarkAddRequest
+import com.on.turip.data.bookmark.dto.BookmarkContentsResponse
+import com.on.turip.data.bookmark.dto.BookmarkCountResponse
+import com.on.turip.domain.common.paging.Cursor
+
+interface BookmarkRemoteDataSource {
+ suspend fun postBookmark(bookmarkAddRequest: BookmarkAddRequest): TuripResult
+
+ suspend fun deleteBookmark(contentId: Long): TuripResult
+
+ suspend fun getBookmarks(cursor: Cursor): TuripResult
+
+ suspend fun getBookmarkCount(): TuripResult
+}
diff --git a/android/app/src/main/java/com/on/turip/data/bookmarks/datasource/DefaultBookmarkRemoteDataSource.kt b/android/app/src/main/java/com/on/turip/data/bookmark/datasource/DefaultBookmarkRemoteDataSource.kt
similarity index 56%
rename from android/app/src/main/java/com/on/turip/data/bookmarks/datasource/DefaultBookmarkRemoteDataSource.kt
rename to android/app/src/main/java/com/on/turip/data/bookmark/datasource/DefaultBookmarkRemoteDataSource.kt
index 7424da24d..c8b8ef50e 100644
--- a/android/app/src/main/java/com/on/turip/data/bookmarks/datasource/DefaultBookmarkRemoteDataSource.kt
+++ b/android/app/src/main/java/com/on/turip/data/bookmark/datasource/DefaultBookmarkRemoteDataSource.kt
@@ -1,10 +1,12 @@
-package com.on.turip.data.bookmarks.datasource
+package com.on.turip.data.bookmark.datasource
import com.on.turip.core.result.TuripResult
-import com.on.turip.data.bookmarks.dto.BookmarkAddRequest
-import com.on.turip.data.bookmarks.dto.BookmarkContentsResponse
-import com.on.turip.data.bookmarks.service.BookmarkService
+import com.on.turip.data.bookmark.dto.BookmarkAddRequest
+import com.on.turip.data.bookmark.dto.BookmarkContentsResponse
+import com.on.turip.data.bookmark.dto.BookmarkCountResponse
+import com.on.turip.data.bookmark.service.BookmarkService
import com.on.turip.data.result.safeApiCall
+import com.on.turip.domain.common.paging.Cursor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -24,11 +26,13 @@ class DefaultBookmarkRemoteDataSource @Inject constructor(
safeApiCall { bookmarkService.deleteBookmark(contentId) }
}
- override suspend fun getBookmarks(
- size: Int,
- lastId: Long,
- ): TuripResult =
+ override suspend fun getBookmarks(cursor: Cursor): TuripResult =
withContext(coroutineContext) {
- safeApiCall { bookmarkService.getBookmarks(size, lastId) }
+ safeApiCall { bookmarkService.getBookmarks(cursor.size, cursor.lastId ?: 0L) }
+ }
+
+ override suspend fun getBookmarkCount(): TuripResult =
+ withContext(coroutineContext) {
+ safeApiCall { bookmarkService.getBookmarkCount() }
}
}
diff --git a/android/app/src/main/java/com/on/turip/data/bookmarks/dto/BookmarkAddRequest.kt b/android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkAddRequest.kt
similarity index 82%
rename from android/app/src/main/java/com/on/turip/data/bookmarks/dto/BookmarkAddRequest.kt
rename to android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkAddRequest.kt
index a49882d66..5a6680200 100644
--- a/android/app/src/main/java/com/on/turip/data/bookmarks/dto/BookmarkAddRequest.kt
+++ b/android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkAddRequest.kt
@@ -1,4 +1,4 @@
-package com.on.turip.data.bookmarks.dto
+package com.on.turip.data.bookmark.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
diff --git a/android/app/src/main/java/com/on/turip/data/bookmarks/dto/BookmarkContentResponse.kt b/android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkContentResponse.kt
similarity index 92%
rename from android/app/src/main/java/com/on/turip/data/bookmarks/dto/BookmarkContentResponse.kt
rename to android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkContentResponse.kt
index b46a6b114..1a43db99a 100644
--- a/android/app/src/main/java/com/on/turip/data/bookmarks/dto/BookmarkContentResponse.kt
+++ b/android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkContentResponse.kt
@@ -1,4 +1,4 @@
-package com.on.turip.data.bookmarks.dto
+package com.on.turip.data.bookmark.dto
import com.on.turip.data.content.dto.ContentResponse
import com.on.turip.data.content.dto.TripDurationInformationResponse
diff --git a/android/app/src/main/java/com/on/turip/data/bookmarks/dto/BookmarkContentsResponse.kt b/android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkContentsResponse.kt
similarity index 87%
rename from android/app/src/main/java/com/on/turip/data/bookmarks/dto/BookmarkContentsResponse.kt
rename to android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkContentsResponse.kt
index 139ca4aa9..fa86764f0 100644
--- a/android/app/src/main/java/com/on/turip/data/bookmarks/dto/BookmarkContentsResponse.kt
+++ b/android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkContentsResponse.kt
@@ -1,4 +1,4 @@
-package com.on.turip.data.bookmarks.dto
+package com.on.turip.data.bookmark.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
diff --git a/android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkCountResponse.kt b/android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkCountResponse.kt
new file mode 100644
index 000000000..2a76a8ba8
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/data/bookmark/dto/BookmarkCountResponse.kt
@@ -0,0 +1,10 @@
+package com.on.turip.data.bookmark.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BookmarkCountResponse(
+ @SerialName("count")
+ val count: Int,
+)
diff --git a/android/app/src/main/java/com/on/turip/data/bookmark/repository/DefaultBookmarkRepository.kt b/android/app/src/main/java/com/on/turip/data/bookmark/repository/DefaultBookmarkRepository.kt
new file mode 100644
index 000000000..e3b9b247f
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/data/bookmark/repository/DefaultBookmarkRepository.kt
@@ -0,0 +1,26 @@
+package com.on.turip.data.bookmark.repository
+
+import com.on.turip.core.result.TuripResult
+import com.on.turip.core.result.mapCatching
+import com.on.turip.data.bookmark.datasource.BookmarkRemoteDataSource
+import com.on.turip.data.bookmark.toDomain
+import com.on.turip.data.bookmark.toRequestDto
+import com.on.turip.domain.bookmark.BookmarkContent
+import com.on.turip.domain.bookmark.repository.BookmarkRepository
+import com.on.turip.domain.common.paging.Cursor
+import com.on.turip.domain.common.paging.Page
+import javax.inject.Inject
+
+class DefaultBookmarkRepository @Inject constructor(
+ private val bookmarkRemoteDataSource: BookmarkRemoteDataSource,
+) : BookmarkRepository {
+ override suspend fun createBookmark(contentId: Long): TuripResult =
+ bookmarkRemoteDataSource.postBookmark(contentId.toRequestDto())
+
+ override suspend fun deleteBookmark(contentId: Long): TuripResult = bookmarkRemoteDataSource.deleteBookmark(contentId)
+
+ override suspend fun loadBookmarks(cursor: Cursor): TuripResult> =
+ bookmarkRemoteDataSource.getBookmarks(cursor).mapCatching { it.toDomain() }
+
+ override suspend fun loadBookmarkCount(): TuripResult = bookmarkRemoteDataSource.getBookmarkCount().mapCatching { it.count }
+}
diff --git a/android/app/src/main/java/com/on/turip/data/bookmarks/service/BookmarkService.kt b/android/app/src/main/java/com/on/turip/data/bookmark/service/BookmarkService.kt
similarity index 67%
rename from android/app/src/main/java/com/on/turip/data/bookmarks/service/BookmarkService.kt
rename to android/app/src/main/java/com/on/turip/data/bookmark/service/BookmarkService.kt
index 81bfce88c..d780c69b2 100644
--- a/android/app/src/main/java/com/on/turip/data/bookmarks/service/BookmarkService.kt
+++ b/android/app/src/main/java/com/on/turip/data/bookmark/service/BookmarkService.kt
@@ -1,7 +1,8 @@
-package com.on.turip.data.bookmarks.service
+package com.on.turip.data.bookmark.service
-import com.on.turip.data.bookmarks.dto.BookmarkAddRequest
-import com.on.turip.data.bookmarks.dto.BookmarkContentsResponse
+import com.on.turip.data.bookmark.dto.BookmarkAddRequest
+import com.on.turip.data.bookmark.dto.BookmarkContentsResponse
+import com.on.turip.data.bookmark.dto.BookmarkCountResponse
import de.jensklingenberg.ktorfit.http.Body
import de.jensklingenberg.ktorfit.http.DELETE
import de.jensklingenberg.ktorfit.http.GET
@@ -24,4 +25,7 @@ interface BookmarkService {
@Query("size") size: Int,
@Query("lastId") lastId: Long,
): BookmarkContentsResponse
+
+ @GET("bookmarks/count")
+ suspend fun getBookmarkCount(): BookmarkCountResponse
}
diff --git a/android/app/src/main/java/com/on/turip/data/bookmarks/BookmarkMapper.kt b/android/app/src/main/java/com/on/turip/data/bookmarks/BookmarkMapper.kt
deleted file mode 100644
index b5023c6e9..000000000
--- a/android/app/src/main/java/com/on/turip/data/bookmarks/BookmarkMapper.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.on.turip.data.bookmarks
-
-import com.on.turip.data.bookmarks.dto.BookmarkAddRequest
-import com.on.turip.data.bookmarks.dto.BookmarkContentResponse
-import com.on.turip.data.bookmarks.dto.BookmarkContentsResponse
-import com.on.turip.data.content.toDomain
-import com.on.turip.domain.bookmark.BookmarkContent
-import com.on.turip.domain.bookmark.PagedBookmarkContents
-
-fun Long.toRequestDto(): BookmarkAddRequest = BookmarkAddRequest(contentId = this)
-
-fun BookmarkContentsResponse.toDomain(): PagedBookmarkContents =
- PagedBookmarkContents(
- bookmarkContents = contents.map { it.toDomain() },
- loadable = loadable,
- )
-
-fun BookmarkContentResponse.toDomain(): BookmarkContent =
- BookmarkContent(
- content = content.toDomain(),
- tripDuration = tripDuration.toDomain(),
- tripPlaceCount = tripPlaceCount,
- )
diff --git a/android/app/src/main/java/com/on/turip/data/bookmarks/datasource/BookmarkRemoteDataSource.kt b/android/app/src/main/java/com/on/turip/data/bookmarks/datasource/BookmarkRemoteDataSource.kt
deleted file mode 100644
index a60455a0e..000000000
--- a/android/app/src/main/java/com/on/turip/data/bookmarks/datasource/BookmarkRemoteDataSource.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.on.turip.data.bookmarks.datasource
-
-import com.on.turip.core.result.TuripResult
-import com.on.turip.data.bookmarks.dto.BookmarkAddRequest
-import com.on.turip.data.bookmarks.dto.BookmarkContentsResponse
-
-interface BookmarkRemoteDataSource {
- suspend fun postBookmark(bookmarkAddRequest: BookmarkAddRequest): TuripResult
-
- suspend fun deleteBookmark(contentId: Long): TuripResult
-
- suspend fun getBookmarks(
- size: Int,
- lastId: Long,
- ): TuripResult
-}
diff --git a/android/app/src/main/java/com/on/turip/data/bookmarks/repository/DefaultBookmarkRepository.kt b/android/app/src/main/java/com/on/turip/data/bookmarks/repository/DefaultBookmarkRepository.kt
deleted file mode 100644
index a9d101b90..000000000
--- a/android/app/src/main/java/com/on/turip/data/bookmarks/repository/DefaultBookmarkRepository.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.on.turip.data.bookmarks.repository
-
-import com.on.turip.core.result.TuripResult
-import com.on.turip.core.result.mapCatching
-import com.on.turip.data.bookmarks.datasource.BookmarkRemoteDataSource
-import com.on.turip.data.bookmarks.toDomain
-import com.on.turip.data.bookmarks.toRequestDto
-import com.on.turip.domain.bookmark.PagedBookmarkContents
-import com.on.turip.domain.bookmark.repository.BookmarkRepository
-import javax.inject.Inject
-
-class DefaultBookmarkRepository @Inject constructor(
- private val bookmarkRemoteDataSource: BookmarkRemoteDataSource,
-) : BookmarkRepository {
- override suspend fun createBookmark(contentId: Long): TuripResult =
- bookmarkRemoteDataSource.postBookmark(contentId.toRequestDto())
-
- override suspend fun deleteBookmark(contentId: Long): TuripResult = bookmarkRemoteDataSource.deleteBookmark(contentId)
-
- override suspend fun loadBookmarks(
- size: Int,
- lastId: Long,
- ): TuripResult = bookmarkRemoteDataSource.getBookmarks(size, lastId).mapCatching { it.toDomain() }
-}
diff --git a/android/app/src/main/java/com/on/turip/di/DataSourceModule.kt b/android/app/src/main/java/com/on/turip/di/DataSourceModule.kt
index 732494988..5450b0b9e 100644
--- a/android/app/src/main/java/com/on/turip/di/DataSourceModule.kt
+++ b/android/app/src/main/java/com/on/turip/di/DataSourceModule.kt
@@ -1,9 +1,9 @@
package com.on.turip.di
-import com.on.turip.data.accounts.datasource.AccountRemoteDataSource
-import com.on.turip.data.accounts.datasource.DefaultAccountRemoteDataSource
-import com.on.turip.data.bookmarks.datasource.BookmarkRemoteDataSource
-import com.on.turip.data.bookmarks.datasource.DefaultBookmarkRemoteDataSource
+import com.on.turip.data.account.datasource.AccountRemoteDataSource
+import com.on.turip.data.account.datasource.DefaultAccountRemoteDataSource
+import com.on.turip.data.bookmark.datasource.BookmarkRemoteDataSource
+import com.on.turip.data.bookmark.datasource.DefaultBookmarkRemoteDataSource
import com.on.turip.data.content.datasource.ContentRemoteDataSource
import com.on.turip.data.content.datasource.DefaultContentRemoteDataSource
import com.on.turip.data.login.datasource.AuthDataSource
diff --git a/android/app/src/main/java/com/on/turip/di/RepositoryModule.kt b/android/app/src/main/java/com/on/turip/di/RepositoryModule.kt
index 1735a5499..6a045ca08 100644
--- a/android/app/src/main/java/com/on/turip/di/RepositoryModule.kt
+++ b/android/app/src/main/java/com/on/turip/di/RepositoryModule.kt
@@ -1,7 +1,7 @@
package com.on.turip.di
-import com.on.turip.data.accounts.repository.DefaultAccountRepository
-import com.on.turip.data.bookmarks.repository.DefaultBookmarkRepository
+import com.on.turip.data.account.repository.DefaultAccountRepository
+import com.on.turip.data.bookmark.repository.DefaultBookmarkRepository
import com.on.turip.data.content.repository.DefaultContentRepository
import com.on.turip.data.login.repository.DefaultAuthRepository
import com.on.turip.data.login.repository.DefaultGuestRepository
@@ -10,7 +10,7 @@ import com.on.turip.data.region.repository.DefaultRegionRepository
import com.on.turip.data.searchhistory.repository.DefaultSearchHistoryRepository
import com.on.turip.data.turip.repository.DefaultTuripRepository
import com.on.turip.data.userstorage.repository.DefaultUserStorageRepository
-import com.on.turip.domain.accounts.AccountRepository
+import com.on.turip.domain.account.AccountRepository
import com.on.turip.domain.bookmark.repository.BookmarkRepository
import com.on.turip.domain.content.repository.ContentRepository
import com.on.turip.domain.login.AuthRepository
diff --git a/android/app/src/main/java/com/on/turip/di/ServiceModule.kt b/android/app/src/main/java/com/on/turip/di/ServiceModule.kt
index 27f09152c..8cede2f3c 100644
--- a/android/app/src/main/java/com/on/turip/di/ServiceModule.kt
+++ b/android/app/src/main/java/com/on/turip/di/ServiceModule.kt
@@ -1,9 +1,9 @@
package com.on.turip.di
-import com.on.turip.data.accounts.service.AccountService
-import com.on.turip.data.accounts.service.createAccountService
-import com.on.turip.data.bookmarks.service.BookmarkService
-import com.on.turip.data.bookmarks.service.createBookmarkService
+import com.on.turip.data.account.service.AccountService
+import com.on.turip.data.account.service.createAccountService
+import com.on.turip.data.bookmark.service.BookmarkService
+import com.on.turip.data.bookmark.service.createBookmarkService
import com.on.turip.data.content.service.ContentService
import com.on.turip.data.content.service.createContentService
import com.on.turip.data.login.service.AuthService
diff --git a/android/app/src/main/java/com/on/turip/domain/accounts/Account.kt b/android/app/src/main/java/com/on/turip/domain/account/Account.kt
similarity index 70%
rename from android/app/src/main/java/com/on/turip/domain/accounts/Account.kt
rename to android/app/src/main/java/com/on/turip/domain/account/Account.kt
index bdb9c1306..106d1cea6 100644
--- a/android/app/src/main/java/com/on/turip/domain/accounts/Account.kt
+++ b/android/app/src/main/java/com/on/turip/domain/account/Account.kt
@@ -1,4 +1,4 @@
-package com.on.turip.domain.accounts
+package com.on.turip.domain.account
data class Account(
val id: Long,
diff --git a/android/app/src/main/java/com/on/turip/domain/accounts/AccountRepository.kt b/android/app/src/main/java/com/on/turip/domain/account/AccountRepository.kt
similarity index 78%
rename from android/app/src/main/java/com/on/turip/domain/accounts/AccountRepository.kt
rename to android/app/src/main/java/com/on/turip/domain/account/AccountRepository.kt
index cf314859a..021ccbe4c 100644
--- a/android/app/src/main/java/com/on/turip/domain/accounts/AccountRepository.kt
+++ b/android/app/src/main/java/com/on/turip/domain/account/AccountRepository.kt
@@ -1,4 +1,4 @@
-package com.on.turip.domain.accounts
+package com.on.turip.domain.account
import com.on.turip.core.result.TuripResult
diff --git a/android/app/src/main/java/com/on/turip/domain/accounts/Role.kt b/android/app/src/main/java/com/on/turip/domain/account/Role.kt
similarity index 88%
rename from android/app/src/main/java/com/on/turip/domain/accounts/Role.kt
rename to android/app/src/main/java/com/on/turip/domain/account/Role.kt
index 49e5bca64..b30725564 100644
--- a/android/app/src/main/java/com/on/turip/domain/accounts/Role.kt
+++ b/android/app/src/main/java/com/on/turip/domain/account/Role.kt
@@ -1,4 +1,4 @@
-package com.on.turip.domain.accounts
+package com.on.turip.domain.account
enum class Role(
private val tag: String,
diff --git a/android/app/src/main/java/com/on/turip/domain/bookmark/PagedBookmarkContents.kt b/android/app/src/main/java/com/on/turip/domain/bookmark/PagedBookmarkContents.kt
deleted file mode 100644
index 3509010d9..000000000
--- a/android/app/src/main/java/com/on/turip/domain/bookmark/PagedBookmarkContents.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.on.turip.domain.bookmark
-
-data class PagedBookmarkContents(
- val bookmarkContents: List,
- val loadable: Boolean,
-)
diff --git a/android/app/src/main/java/com/on/turip/domain/bookmark/repository/BookmarkRepository.kt b/android/app/src/main/java/com/on/turip/domain/bookmark/repository/BookmarkRepository.kt
index 256caa5a5..9d56f7bdf 100644
--- a/android/app/src/main/java/com/on/turip/domain/bookmark/repository/BookmarkRepository.kt
+++ b/android/app/src/main/java/com/on/turip/domain/bookmark/repository/BookmarkRepository.kt
@@ -1,15 +1,16 @@
package com.on.turip.domain.bookmark.repository
import com.on.turip.core.result.TuripResult
-import com.on.turip.domain.bookmark.PagedBookmarkContents
+import com.on.turip.domain.bookmark.BookmarkContent
+import com.on.turip.domain.common.paging.Cursor
+import com.on.turip.domain.common.paging.Page
interface BookmarkRepository {
suspend fun createBookmark(contentId: Long): TuripResult
suspend fun deleteBookmark(contentId: Long): TuripResult
- suspend fun loadBookmarks(
- size: Int,
- lastId: Long,
- ): TuripResult
+ suspend fun loadBookmarks(cursor: Cursor): TuripResult>
+
+ suspend fun loadBookmarkCount(): TuripResult
}
diff --git a/android/app/src/main/java/com/on/turip/domain/common/paging/Cursor.kt b/android/app/src/main/java/com/on/turip/domain/common/paging/Cursor.kt
new file mode 100644
index 000000000..8934499c2
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/domain/common/paging/Cursor.kt
@@ -0,0 +1,6 @@
+package com.on.turip.domain.common.paging
+
+data class Cursor(
+ val size: Int,
+ val lastId: Long?,
+)
diff --git a/android/app/src/main/java/com/on/turip/domain/common/paging/Page.kt b/android/app/src/main/java/com/on/turip/domain/common/paging/Page.kt
new file mode 100644
index 000000000..011e74684
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/domain/common/paging/Page.kt
@@ -0,0 +1,6 @@
+package com.on.turip.domain.common.paging
+
+data class Page(
+ val items: List,
+ val hasNext: Boolean,
+)
diff --git a/android/app/src/main/java/com/on/turip/ui/bookmarks/BookmarkContentActivity.kt b/android/app/src/main/java/com/on/turip/ui/bookmarks/BookmarkContentActivity.kt
new file mode 100644
index 000000000..79346a6cb
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/bookmarks/BookmarkContentActivity.kt
@@ -0,0 +1,73 @@
+package com.on.turip.ui.bookmarks
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import com.on.turip.ui.compose.bookmark.BookmarkContentListScreen
+import com.on.turip.ui.compose.designsystem.theme.TuripTheme
+import com.on.turip.ui.login.LoginActivity
+import com.on.turip.ui.trip.TripDetailActivity
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class BookmarkContentActivity : AppCompatActivity() {
+ private var hasBookmarkChanges = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ hasBookmarkChanges =
+ savedInstanceState?.getBoolean(EXTRA_BOOKMARK_CONTENT_HAS_BOOKMARK_CHANGES, false)
+ ?: false
+ setContent {
+ TuripTheme {
+ BookmarkContentListScreen(
+ onBack = {
+ finish()
+ },
+ onNavigateToLogin = {
+ val intent: Intent =
+ LoginActivity.newIntent(this).apply {
+ flags =
+ Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ startActivity(intent)
+ finish()
+ },
+ onNavigateToContent = { contentId: Long ->
+ val intent: Intent =
+ TripDetailActivity.newIntent(context = this, contentId = contentId)
+ startActivity(intent)
+ },
+ onBookmarkChanged = {
+ hasBookmarkChanges = true
+ },
+ )
+ }
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putBoolean(EXTRA_BOOKMARK_CONTENT_HAS_BOOKMARK_CHANGES, hasBookmarkChanges)
+ }
+
+ override fun finish() {
+ val data =
+ Intent().apply {
+ putExtra(EXTRA_BOOKMARK_CONTENT_HAS_BOOKMARK_CHANGES, hasBookmarkChanges)
+ }
+ setResult(RESULT_OK, data)
+ super.finish()
+ }
+
+ companion object {
+ const val EXTRA_BOOKMARK_CONTENT_HAS_BOOKMARK_CHANGES =
+ "com.on.turip.ui.bookmarks.BOOKMARK_CHANGES"
+
+ fun newIntent(context: Context): Intent = Intent(context, BookmarkContentActivity::class.java)
+ }
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/mypage/component/BookmarkedContentItem.kt b/android/app/src/main/java/com/on/turip/ui/common/component/bookmark/BookmarkContentMetaSection.kt
similarity index 52%
rename from android/app/src/main/java/com/on/turip/ui/compose/mypage/component/BookmarkedContentItem.kt
rename to android/app/src/main/java/com/on/turip/ui/common/component/bookmark/BookmarkContentMetaSection.kt
index 53caccb94..751709bac 100644
--- a/android/app/src/main/java/com/on/turip/ui/compose/mypage/component/BookmarkedContentItem.kt
+++ b/android/app/src/main/java/com/on/turip/ui/common/component/bookmark/BookmarkContentMetaSection.kt
@@ -1,37 +1,25 @@
-package com.on.turip.ui.compose.mypage.component
+package com.on.turip.ui.common.component.bookmark
import androidx.annotation.DrawableRes
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
-import coil3.request.ImageRequest
-import coil3.request.crossfade
import com.on.turip.R
import com.on.turip.domain.bookmark.BookmarkContent
import com.on.turip.domain.content.Content
@@ -39,96 +27,18 @@ import com.on.turip.domain.content.video.VideoData
import com.on.turip.domain.creator.Creator
import com.on.turip.domain.region.City
import com.on.turip.domain.trip.TripDuration
-import com.on.turip.ui.common.TuripUrlConverter
import com.on.turip.ui.common.mapper.toUiModel
import com.on.turip.ui.compose.designsystem.theme.TuripTheme
@Composable
-fun BookmarkedContentItem(
+fun BookmarkContentMetaSection(
item: BookmarkContent,
- onContentClick: (contentId: Long) -> Unit,
onRemoveBookmark: (contentId: Long) -> Unit,
modifier: Modifier = Modifier,
-) {
- Column(
- modifier =
- modifier
- .clip(TuripTheme.shape.container)
- .clickable { onContentClick(item.content.id) }
- .border(1.dp, TuripTheme.colors.border, TuripTheme.shape.container)
- .padding(TuripTheme.spacing.extraSmall),
- ) {
- ContentThumbnail(
- imageUrl = item.content.videoData.url,
- modifier = Modifier.fillMaxWidth(),
- )
-
- Spacer(modifier = Modifier.height(TuripTheme.spacing.medium))
-
- Text(
- text = item.content.videoData.title,
- style = TuripTheme.typography.title2,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(horizontal = TuripTheme.spacing.extraSmall),
- )
-
- Spacer(modifier = Modifier.height(TuripTheme.spacing.small))
-
- ContentInformation(
- item = item,
- onRemoveBookmark = onRemoveBookmark,
- )
- }
-}
-
-@Composable
-private fun ContentThumbnail(
- imageUrl: String,
- modifier: Modifier = Modifier,
- contentDescription: String? = null,
-) {
- val shape = TuripTheme.shape.container
- val parsedUrl = TuripUrlConverter.convertVideoThumbnailUrl(imageUrl)
-
- Box(
- modifier =
- modifier
- .fillMaxWidth()
- .aspectRatio(16f / 9f)
- .clip(shape)
- .border(
- width = 1.dp,
- color = TuripTheme.colors.gray01,
- shape = shape,
- ),
- ) {
- AsyncImage(
- model =
- ImageRequest
- .Builder(LocalContext.current)
- .data(parsedUrl)
- .crossfade(true)
- .build(),
- contentDescription = contentDescription,
- contentScale = ContentScale.Crop,
- modifier = Modifier.matchParentSize(),
- error = painterResource(R.drawable.bg_image_placeholder),
- )
- }
-}
-
-@Composable
-private fun ContentInformation(
- item: BookmarkContent,
- onRemoveBookmark: (contentId: Long) -> Unit,
) {
Row(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = TuripTheme.spacing.extraSmall),
- verticalAlignment = Alignment.Top,
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
@@ -203,26 +113,26 @@ private fun ContentInfoItem(
@Preview(showBackground = true)
@Composable
-private fun BookmarkedContentItemPreview() {
- val content =
- BookmarkContent(
- content =
- Content(
- 1L,
- Creator(1L, "채널명", ""),
- VideoData("콘텐츠 제목이 길면 ...으로 표시되는 것을 확인 ㅇㅇㅇ", "thumbnail", "1박 2일"),
- City(""),
- true,
- ),
- tripDuration = TripDuration(1, 2),
- tripPlaceCount = 2,
- )
+private fun BookmarkContentMetaSectionPreview() {
TuripTheme {
- BookmarkedContentItem(
+ val content =
+ BookmarkContent(
+ content =
+ Content(
+ 1L,
+ Creator(1L, "채널명", ""),
+ VideoData("", "", "2026-02-12"),
+ City(""),
+ true,
+ ),
+ tripDuration = TripDuration(0, 1),
+ tripPlaceCount = 100,
+ )
+
+ BookmarkContentMetaSection(
item = content,
- onContentClick = {},
onRemoveBookmark = {},
- modifier = Modifier.width(280.dp),
+ modifier = Modifier.padding(TuripTheme.spacing.large),
)
}
}
diff --git a/android/app/src/main/java/com/on/turip/ui/common/component/bookmark/BookmarkContentTitleRow.kt b/android/app/src/main/java/com/on/turip/ui/common/component/bookmark/BookmarkContentTitleRow.kt
new file mode 100644
index 000000000..c46803bb2
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/common/component/bookmark/BookmarkContentTitleRow.kt
@@ -0,0 +1,72 @@
+package com.on.turip.ui.common.component.bookmark
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import com.on.turip.ui.compose.designsystem.theme.TuripTheme
+
+@Composable
+fun BookmarkContentTitleRow(
+ title: String,
+ modifier: Modifier = Modifier,
+ trailing: (@Composable () -> Unit)? = null,
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = title,
+ style = TuripTheme.typography.title2,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ trailing?.invoke()
+ }
+}
+
+@Preview(showBackground = true, name = "칩 포함")
+@Composable
+private fun BookmarkContentTitleRowWithTrailingPreview() {
+ TuripTheme {
+ BookmarkContentTitleRow(
+ title = "콘텐츠 제목이 길어질 경우 말줄임 처리 확인용 텍스트입니다",
+ trailing = {
+ Text(
+ text = "테스트",
+ modifier =
+ Modifier
+ .background(TuripTheme.colors.primary)
+ .padding(TuripTheme.spacing.medium),
+ )
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(TuripTheme.spacing.large),
+ )
+ }
+}
+
+@Preview(showBackground = true, name = "타이틀만 존재")
+@Composable
+private fun BookmarkContentTitleRowWithoutTrailingPreview() {
+ TuripTheme {
+ BookmarkContentTitleRow(
+ title = "타이틀만 있는 경우",
+ trailing = null,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(TuripTheme.spacing.large),
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/common/component/content/ContentThumbnail.kt b/android/app/src/main/java/com/on/turip/ui/common/component/content/ContentThumbnail.kt
new file mode 100644
index 000000000..968dd50da
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/common/component/content/ContentThumbnail.kt
@@ -0,0 +1,72 @@
+package com.on.turip.ui.common.component.content
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.crossfade
+import com.on.turip.R
+import com.on.turip.ui.common.TuripUrlConverter
+import com.on.turip.ui.compose.designsystem.theme.TuripTheme
+
+@Composable
+fun ContentThumbnail(
+ imageUrl: String,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
+) {
+ val shape = TuripTheme.shape.container
+ val parsedUrl = TuripUrlConverter.convertVideoThumbnailUrl(imageUrl)
+
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .aspectRatio(16f / 9f)
+ .clip(shape)
+ .border(
+ width = 1.dp,
+ color = TuripTheme.colors.gray01,
+ shape = shape,
+ ),
+ ) {
+ AsyncImage(
+ model =
+ ImageRequest
+ .Builder(LocalContext.current)
+ .data(parsedUrl)
+ .crossfade(true)
+ .build(),
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.matchParentSize(),
+ error = painterResource(R.drawable.bg_image_placeholder),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContentThumbnailPreview() {
+ TuripTheme {
+ ContentThumbnail(
+ imageUrl = "",
+ modifier =
+ Modifier
+ .background(TuripTheme.colors.primary)
+ .padding(TuripTheme.spacing.small),
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/common/paging/PagingLoadMode.kt b/android/app/src/main/java/com/on/turip/ui/common/paging/PagingLoadMode.kt
new file mode 100644
index 000000000..3690eff72
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/common/paging/PagingLoadMode.kt
@@ -0,0 +1,6 @@
+package com.on.turip.ui.common.paging
+
+enum class PagingLoadMode {
+ REFRESH,
+ APPEND,
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/common/paging/PagingState.kt b/android/app/src/main/java/com/on/turip/ui/common/paging/PagingState.kt
new file mode 100644
index 000000000..153ac917b
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/common/paging/PagingState.kt
@@ -0,0 +1,13 @@
+package com.on.turip.ui.common.paging
+
+import androidx.compose.runtime.Immutable
+import com.on.turip.ui.common.error.ErrorUiState
+import kotlinx.collections.immutable.ImmutableList
+
+@Immutable
+data class PagingState(
+ val items: ImmutableList,
+ val hasNext: Boolean,
+ val isAppending: Boolean,
+ val errorUiState: ErrorUiState,
+)
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListScreen.kt b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListScreen.kt
new file mode 100644
index 000000000..0bb22dd45
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListScreen.kt
@@ -0,0 +1,564 @@
+package com.on.turip.ui.compose.bookmark
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.on.turip.R
+import com.on.turip.domain.bookmark.BookmarkContent
+import com.on.turip.domain.content.Content
+import com.on.turip.domain.content.video.VideoData
+import com.on.turip.domain.creator.Creator
+import com.on.turip.domain.region.City
+import com.on.turip.domain.trip.TripDuration
+import com.on.turip.ui.common.error.ErrorUiState
+import com.on.turip.ui.common.extensions.showSnackbarWithAction
+import com.on.turip.ui.common.paging.PagingState
+import com.on.turip.ui.compose.bookmark.component.BookmarkContentListAppBar
+import com.on.turip.ui.compose.bookmark.component.BookmarkContentListItem
+import com.on.turip.ui.compose.designsystem.component.ErrorScreen
+import com.on.turip.ui.compose.designsystem.component.TuripSnackbar
+import com.on.turip.ui.compose.designsystem.theme.TuripTheme
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+
+@Composable
+fun BookmarkContentListScreen(
+ onBack: () -> Unit,
+ onNavigateToLogin: () -> Unit,
+ onNavigateToContent: (contentId: Long) -> Unit,
+ onBookmarkChanged: () -> Unit,
+ viewModel: BookmarkContentListViewModel = hiltViewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ val snackbarHostState = remember { SnackbarHostState() }
+ val resources = LocalResources.current
+
+ LaunchedEffect(Unit) {
+ viewModel.uiEffect.collect { uiEffect: BookmarkContentListUiEffect ->
+ when (uiEffect) {
+ BookmarkContentListUiEffect.NavigateToLogin -> {
+ onNavigateToLogin()
+ }
+
+ BookmarkContentListUiEffect.BookmarkRemovedList -> {
+ onBookmarkChanged()
+ }
+
+ BookmarkContentListUiEffect.ShowBookmarkRemoveFailedList -> {
+ snackbarHostState.showSnackbarWithAction(
+ message = resources.getString(R.string.my_page_snackbar_bookmark_remove_failed),
+ actionLabel = resources.getString(R.string.my_page_snackbar_bookmark_remove_failed_action),
+ onAction = viewModel::refreshBookmarkContents,
+ )
+ }
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = { BookmarkContentListAppBar(onBackClick = onBack) },
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(TuripTheme.colors.white)
+ .systemBarsPadding(),
+ snackbarHost = { TuripSnackbar(snackbarHostState = snackbarHostState) },
+ ) { innerPadding ->
+ BookmarkContentListContent(
+ uiState = uiState,
+ onRetryClick = viewModel::refreshBookmarkContents,
+ onContentClick = onNavigateToContent,
+ onBookmarkClick = viewModel::removeBookmark,
+ onLoadMore = viewModel::loadMoreContents,
+ modifier = Modifier.padding(innerPadding),
+ )
+ }
+}
+
+@Composable
+private fun BookmarkContentListContent(
+ uiState: BookmarkContentListUiState,
+ onRetryClick: () -> Unit,
+ onContentClick: (contentId: Long) -> Unit,
+ onBookmarkClick: (contentId: Long) -> Unit,
+ onLoadMore: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier.fillMaxSize()) {
+ when {
+ uiState.isLoading -> {
+ BookmarkContentListLoading()
+ }
+
+ uiState.errorUiState != ErrorUiState.None -> {
+ ErrorScreen(
+ errorUiState = uiState.errorUiState,
+ onRetryClick = onRetryClick,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ else -> {
+ if (uiState.isEmpty) {
+ BookmarkContentListEmpty()
+ } else {
+ BookmarkContentList(
+ uiState = uiState,
+ onContentClick = onContentClick,
+ onBookmarkClick = onBookmarkClick,
+ onLoadMore = onLoadMore,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BookmarkContentListLoading(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(60.dp),
+ color = TuripTheme.colors.black,
+ )
+ }
+}
+
+@Composable
+private fun BookmarkContentListEmpty(modifier: Modifier = Modifier) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = modifier.fillMaxSize(),
+ ) {
+ Spacer(modifier = Modifier.height(TuripTheme.spacing.huge))
+
+ Image(
+ painter = painterResource(R.drawable.mascot),
+ contentDescription = stringResource(R.string.all_mascot_description),
+ modifier = Modifier.size(70.dp),
+ )
+
+ Spacer(modifier = Modifier.height(TuripTheme.spacing.large))
+
+ Text(
+ text = stringResource(R.string.my_page_empty_bookmark_content),
+ style = TuripTheme.typography.title2,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(TuripTheme.spacing.extraHuge))
+ }
+}
+
+@Composable
+private fun BookmarkContentList(
+ uiState: BookmarkContentListUiState,
+ onContentClick: (contentId: Long) -> Unit,
+ onBookmarkClick: (contentId: Long) -> Unit,
+ onLoadMore: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val pagingState: PagingState = uiState.bookmarkContents
+ val listState = rememberLazyListState()
+ val threshold = 3
+ val shouldLoadMore by remember {
+ derivedStateOf {
+ if (!pagingState.hasNext || pagingState.isAppending || pagingState.errorUiState != ErrorUiState.None ||
+ pagingState.items.isEmpty()
+ ) {
+ return@derivedStateOf false
+ }
+
+ val layoutInfo = listState.layoutInfo
+ val totalCount = layoutInfo.totalItemsCount
+ val lastVisibleIndex =
+ layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
+ val lastIndex = totalCount - 1
+
+ lastVisibleIndex >= lastIndex - threshold
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ snapshotFlow { shouldLoadMore }
+ .distinctUntilChanged()
+ .filter { it }
+ .collect { onLoadMore() }
+ }
+
+ val totalBookmarkCount =
+ if (uiState.totalBookmarkCount != null) {
+ stringResource(R.string.bookmark_content_count, uiState.totalBookmarkCount)
+ } else {
+ stringResource(R.string.bookmark_content_count_fail)
+ }
+
+ Column(modifier = modifier) {
+ Text(
+ text = totalBookmarkCount,
+ textAlign = TextAlign.End,
+ style = TuripTheme.typography.info2,
+ color = TuripTheme.colors.gray03,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = TuripTheme.spacing.medium)
+ .padding(end = TuripTheme.spacing.large),
+ )
+
+ HorizontalDivider(
+ modifier = Modifier.fillMaxWidth(),
+ thickness = 5.dp,
+ color = TuripTheme.colors.gray01,
+ )
+
+ LazyColumn(
+ state = listState,
+ contentPadding = PaddingValues(TuripTheme.spacing.medium),
+ modifier = Modifier.weight(1f),
+ ) {
+ itemsIndexed(
+ items = pagingState.items,
+ key = { _, item -> item.content.id },
+ ) { index, content ->
+ BookmarkContentListItem(
+ content = content,
+ onContentClick = onContentClick,
+ onRemoveBookmark = onBookmarkClick,
+ )
+
+ if (index != pagingState.items.lastIndex) {
+ HorizontalDivider(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = TuripTheme.spacing.medium),
+ thickness = 1.dp,
+ color = TuripTheme.colors.gray01,
+ )
+ }
+ }
+
+ if (pagingState.isAppending) {
+ item {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(TuripTheme.spacing.large),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = TuripTheme.colors.black,
+ )
+ }
+ }
+ } else if (pagingState.errorUiState != ErrorUiState.None) {
+ item {
+ LoadMoreError(onRetryClick = onLoadMore)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoadMoreError(
+ onRetryClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(horizontal = TuripTheme.spacing.extraSmall),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.bookmark_content_load_more_fail_title),
+ style = TuripTheme.typography.info1,
+ modifier = Modifier.weight(1f),
+ )
+
+ TextButton(onClick = onRetryClick) {
+ Text(
+ text = stringResource(R.string.retry),
+ style = TuripTheme.typography.info2,
+ color = TuripTheme.colors.gray04,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "로딩")
+@Composable
+private fun BookmarkContentListLoadingPreview() {
+ TuripTheme {
+ BookmarkContentListContent(
+ uiState = BookmarkContentListUiState.Idle,
+ onRetryClick = {},
+ onContentClick = {},
+ onBookmarkClick = {},
+ onLoadMore = { },
+ )
+ }
+}
+
+@Preview(showBackground = true, name = "북마크 콘텐츠 비어 있는 경우")
+@Composable
+private fun BookmarkContentListEmptyPreview() {
+ TuripTheme {
+ BookmarkContentListContent(
+ uiState =
+ BookmarkContentListUiState(
+ isLoading = false,
+ bookmarkContents =
+ PagingState(
+ items = persistentListOf(),
+ hasNext = false,
+ isAppending = false,
+ errorUiState = ErrorUiState.None,
+ ),
+ totalBookmarkCount = null,
+ errorUiState = ErrorUiState.None,
+ ),
+ onRetryClick = {},
+ onContentClick = {},
+ onBookmarkClick = {},
+ onLoadMore = { },
+ )
+ }
+}
+
+@Preview(showBackground = true, name = "에러")
+@Composable
+private fun BookmarkContentListErrorPreview() {
+ TuripTheme {
+ BookmarkContentListContent(
+ uiState =
+ BookmarkContentListUiState(
+ isLoading = false,
+ bookmarkContents =
+ PagingState(
+ items = persistentListOf(),
+ hasNext = false,
+ isAppending = false,
+ errorUiState = ErrorUiState.None,
+ ),
+ totalBookmarkCount = null,
+ errorUiState = ErrorUiState.Network,
+ ),
+ onRetryClick = {},
+ onContentClick = {},
+ onBookmarkClick = {},
+ onLoadMore = { },
+ )
+ }
+}
+
+@Preview(showBackground = true, name = "북마크 콘텐츠 수 조회 API만 에러")
+@Composable
+private fun BookmarkContentListLoadCountErrorPreview() {
+ val contents =
+ persistentListOf(
+ BookmarkContent(
+ content =
+ Content(
+ 1L,
+ Creator(1L, "채널명", ""),
+ VideoData("콘텐츠 제목이 길면 ...으로 표시되는 것을 확인 ㅇㅇㅇ", "thumbnail", "2026-01-12"),
+ City("대구"),
+ true,
+ ),
+ tripDuration = TripDuration(1, 2),
+ tripPlaceCount = 2,
+ ),
+ BookmarkContent(
+ content =
+ Content(
+ 2L,
+ Creator(1L, "채널명이 길어지는 경우 채널명이 길어지는 경우 채널명이 길어지는 경우 채널명이 길어지는 경우 ", ""),
+ VideoData("콘텐츠 제목", "thumbnail", "2025-01-12"),
+ City("대구"),
+ true,
+ ),
+ tripDuration = TripDuration(0, 1),
+ tripPlaceCount = 2,
+ ),
+ )
+ TuripTheme {
+ Column {
+ BookmarkContentListContent(
+ uiState =
+ BookmarkContentListUiState(
+ isLoading = false,
+ bookmarkContents =
+ PagingState(
+ items = contents,
+ hasNext = false,
+ isAppending = false,
+ errorUiState = ErrorUiState.None,
+ ),
+ totalBookmarkCount = null,
+ errorUiState = ErrorUiState.None,
+ ),
+ onRetryClick = {},
+ onContentClick = {},
+ onBookmarkClick = {},
+ onLoadMore = {},
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "더보기 에러")
+@Composable
+private fun BookmarkContentLoadMoreErrorPreview() {
+ val contents =
+ persistentListOf(
+ BookmarkContent(
+ content =
+ Content(
+ 1L,
+ Creator(1L, "채널명", ""),
+ VideoData("콘텐츠 제목", "thumbnail", "2026-01-12"),
+ City("대구"),
+ true,
+ ),
+ tripDuration = TripDuration(1, 2),
+ tripPlaceCount = 2,
+ ),
+ BookmarkContent(
+ content =
+ Content(
+ 2L,
+ Creator(2L, "다른 채널", ""),
+ VideoData("두 번째 콘텐츠", "thumbnail", "2025-01-12"),
+ City("서울"),
+ true,
+ ),
+ tripDuration = TripDuration(0, 1),
+ tripPlaceCount = 1,
+ ),
+ )
+
+ TuripTheme {
+ BookmarkContentListContent(
+ uiState =
+ BookmarkContentListUiState(
+ isLoading = false,
+ bookmarkContents =
+ PagingState(
+ items = contents,
+ hasNext = true,
+ isAppending = false,
+ errorUiState = ErrorUiState.Network,
+ ),
+ totalBookmarkCount = 2,
+ errorUiState = ErrorUiState.None,
+ ),
+ onRetryClick = {},
+ onContentClick = {},
+ onBookmarkClick = {},
+ onLoadMore = {},
+ )
+ }
+}
+
+@Preview(showBackground = true, name = "정상")
+@Composable
+private fun BookmarkContentListSuccessPreview() {
+ val contents =
+ persistentListOf(
+ BookmarkContent(
+ content =
+ Content(
+ 1L,
+ Creator(1L, "채널명", ""),
+ VideoData("콘텐츠 제목이 길면 ...으로 표시되는 것을 확인 ㅇㅇㅇ", "thumbnail", "2026-01-12"),
+ City("대구"),
+ true,
+ ),
+ tripDuration = TripDuration(1, 2),
+ tripPlaceCount = 2,
+ ),
+ BookmarkContent(
+ content =
+ Content(
+ 2L,
+ Creator(1L, "채널명이 길어지는 경우 채널명이 길어지는 경우 채널명이 길어지는 경우 채널명이 길어지는 경우 ", ""),
+ VideoData("콘텐츠 제목", "thumbnail", "2025-01-12"),
+ City("대구"),
+ true,
+ ),
+ tripDuration = TripDuration(0, 1),
+ tripPlaceCount = 2,
+ ),
+ )
+ TuripTheme {
+ Column {
+ BookmarkContentListContent(
+ uiState =
+ BookmarkContentListUiState(
+ isLoading = false,
+ bookmarkContents =
+ PagingState(
+ items = contents,
+ hasNext = false,
+ isAppending = false,
+ errorUiState = ErrorUiState.None,
+ ),
+ totalBookmarkCount = 10,
+ errorUiState = ErrorUiState.None,
+ ),
+ onRetryClick = {},
+ onContentClick = {},
+ onBookmarkClick = {},
+ onLoadMore = {},
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListUiEffect.kt b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListUiEffect.kt
new file mode 100644
index 000000000..4fa665903
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListUiEffect.kt
@@ -0,0 +1,9 @@
+package com.on.turip.ui.compose.bookmark
+
+sealed interface BookmarkContentListUiEffect {
+ data object NavigateToLogin : BookmarkContentListUiEffect
+
+ data object ShowBookmarkRemoveFailedList : BookmarkContentListUiEffect
+
+ data object BookmarkRemovedList : BookmarkContentListUiEffect
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListUiState.kt b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListUiState.kt
new file mode 100644
index 000000000..179dc0146
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListUiState.kt
@@ -0,0 +1,34 @@
+package com.on.turip.ui.compose.bookmark
+
+import androidx.compose.runtime.Immutable
+import com.on.turip.domain.bookmark.BookmarkContent
+import com.on.turip.ui.common.error.ErrorUiState
+import com.on.turip.ui.common.paging.PagingState
+import kotlinx.collections.immutable.persistentListOf
+
+@Immutable
+data class BookmarkContentListUiState(
+ val isLoading: Boolean,
+ val bookmarkContents: PagingState,
+ val totalBookmarkCount: Int?,
+ val errorUiState: ErrorUiState,
+) {
+ val isEmpty: Boolean
+ get() = !isLoading && bookmarkContents.items.isEmpty() && errorUiState == ErrorUiState.None
+
+ companion object {
+ val Idle: BookmarkContentListUiState =
+ BookmarkContentListUiState(
+ isLoading = true,
+ bookmarkContents =
+ PagingState(
+ items = persistentListOf(),
+ hasNext = false,
+ isAppending = false,
+ errorUiState = ErrorUiState.None,
+ ),
+ totalBookmarkCount = null,
+ errorUiState = ErrorUiState.None,
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListViewModel.kt b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListViewModel.kt
new file mode 100644
index 000000000..0df373135
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/BookmarkContentListViewModel.kt
@@ -0,0 +1,272 @@
+package com.on.turip.ui.compose.bookmark
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.on.turip.core.result.ErrorType
+import com.on.turip.core.result.onFailure
+import com.on.turip.core.result.onSuccess
+import com.on.turip.domain.bookmark.BookmarkContent
+import com.on.turip.domain.bookmark.repository.BookmarkRepository
+import com.on.turip.domain.common.paging.Cursor
+import com.on.turip.domain.common.paging.Page
+import com.on.turip.ui.common.error.ErrorUiState
+import com.on.turip.ui.common.error.UiError
+import com.on.turip.ui.common.error.toUiError
+import com.on.turip.ui.common.paging.PagingLoadMode
+import com.on.turip.ui.common.paging.PagingState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class BookmarkContentListViewModel @Inject constructor(
+ private val bookmarkRepository: BookmarkRepository,
+) : ViewModel() {
+ private val _uiState: MutableStateFlow =
+ MutableStateFlow(BookmarkContentListUiState.Idle)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _uiEffect: Channel = Channel(Channel.BUFFERED)
+ val uiEffect: Flow = _uiEffect.receiveAsFlow()
+
+ init {
+ refreshBookmarkContents()
+ }
+
+ fun refreshBookmarkContents() {
+ loadBookmarkContents(PagingLoadMode.REFRESH)
+ }
+
+ fun loadMoreContents() {
+ loadBookmarkContents(PagingLoadMode.APPEND)
+ }
+
+ private fun loadBookmarkContents(loadMode: PagingLoadMode) {
+ viewModelScope.launch {
+ if (!prepareLoadingState(loadMode)) return@launch
+
+ // 새로고침할 때만 전체 콘텐츠 수 API 호출
+ if (loadMode == PagingLoadMode.REFRESH) launch { loadBookmarkCount() }
+
+ val lastItemId: Long? =
+ uiState.value.bookmarkContents.items
+ .lastOrNull()
+ ?.content
+ ?.id
+
+ val cursor = Cursor(size = PAGE_SIZE, lastId = lastItemId)
+ bookmarkRepository
+ .loadBookmarks(cursor)
+ .onSuccess { result: Page ->
+ Timber.d("북마크 화면 조회 성공 mode = $loadMode, cursor = $cursor")
+ applyBookmarkContents(loadMode, result)
+ }.onFailure { errorType: ErrorType ->
+ Timber.e("북마크 화면 에러 loadMode = $loadMode")
+ val uiError: UiError.Global = errorType.toUiError() as UiError.Global
+ when (loadMode) {
+ PagingLoadMode.REFRESH -> applyBookmarkContentsRefreshFailure(uiError)
+ PagingLoadMode.APPEND -> applyBookmarkContentsAppendFailure(uiError)
+ }
+ }
+ }
+ }
+
+ private fun prepareLoadingState(loadMode: PagingLoadMode): Boolean {
+ return when (loadMode) {
+ PagingLoadMode.REFRESH -> {
+ _uiState.update { state ->
+ state.copy(
+ isLoading = true,
+ errorUiState = ErrorUiState.None,
+ bookmarkContents =
+ state.bookmarkContents.copy(
+ isAppending = false,
+ errorUiState = ErrorUiState.None,
+ ),
+ )
+ }
+ true
+ }
+
+ PagingLoadMode.APPEND -> {
+ val pagingState = uiState.value.bookmarkContents
+ val canAppend =
+ pagingState.hasNext && pagingState.items.isNotEmpty() && !pagingState.isAppending
+ if (!canAppend) return false
+
+ _uiState.update { state ->
+ state.copy(
+ isLoading = false,
+ bookmarkContents =
+ state.bookmarkContents.copy(
+ isAppending = true,
+ errorUiState = ErrorUiState.None,
+ ),
+ errorUiState = ErrorUiState.None,
+ )
+ }
+ true
+ }
+ }
+ }
+
+ private suspend fun loadBookmarkCount() {
+ bookmarkRepository
+ .loadBookmarkCount()
+ .onSuccess { count: Int ->
+ _uiState.update { state -> state.copy(totalBookmarkCount = count) }
+ }.onFailure {
+ _uiState.update { state -> state.copy(totalBookmarkCount = null) }
+ }
+ }
+
+ private fun applyBookmarkContents(
+ loadMode: PagingLoadMode,
+ result: Page,
+ ) {
+ _uiState.update { state ->
+ val newItems =
+ when (loadMode) {
+ PagingLoadMode.REFRESH -> result.items.toImmutableList()
+ PagingLoadMode.APPEND -> (state.bookmarkContents.items + result.items).toImmutableList()
+ }
+ state.copy(
+ isLoading = false,
+ bookmarkContents =
+ PagingState(
+ items = newItems,
+ hasNext = result.hasNext,
+ isAppending = false,
+ errorUiState = ErrorUiState.None,
+ ),
+ errorUiState = ErrorUiState.None,
+ )
+ }
+ }
+
+ private suspend fun applyBookmarkContentsRefreshFailure(uiError: UiError.Global) {
+ when (uiError) {
+ UiError.Global.Network -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorUiState = ErrorUiState.Network,
+ )
+ }
+ }
+
+ UiError.Global.Server -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorUiState = ErrorUiState.Server,
+ )
+ }
+ }
+
+ UiError.Global.TokenExpired -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _uiEffect.send(BookmarkContentListUiEffect.NavigateToLogin)
+ }
+ }
+ }
+
+ private suspend fun applyBookmarkContentsAppendFailure(uiError: UiError.Global) {
+ when (uiError) {
+ UiError.Global.Network -> {
+ _uiState.update { state ->
+ state.copy(
+ bookmarkContents =
+ state.bookmarkContents.copy(
+ isAppending = false,
+ errorUiState = ErrorUiState.Network,
+ ),
+ )
+ }
+ }
+
+ UiError.Global.Server -> {
+ _uiState.update { state ->
+ state.copy(
+ bookmarkContents =
+ state.bookmarkContents.copy(
+ isAppending = false,
+ errorUiState = ErrorUiState.Server,
+ ),
+ )
+ }
+ }
+
+ UiError.Global.TokenExpired -> {
+ _uiState.update { state ->
+ state.copy(bookmarkContents = state.bookmarkContents.copy(isAppending = false))
+ }
+ _uiEffect.send(BookmarkContentListUiEffect.NavigateToLogin)
+ }
+ }
+ }
+
+ private val removeBookmarkMutex = Mutex()
+
+ // 삭제 진행 중인 콘텐츠에 대해 중복 API 호출 방지용
+ private val removingIds = mutableSetOf()
+
+ // 낙관적 UI
+ fun removeBookmark(contentId: Long) {
+ viewModelScope.launch {
+ val acquired = removeBookmarkMutex.withLock { removingIds.add(contentId) }
+ // 이미 삭제 진행 중이라면 반환
+ if (!acquired) return@launch
+
+ try {
+ val removed =
+ removeBookmarkMutex.withLock {
+ val contents = _uiState.value.bookmarkContents
+
+ // 이미 UI 제거 완료된 상태 (API 호출 완료)
+ if (contents.items.none { it.content.id == contentId }) return@withLock false
+
+ val updated =
+ contents.items.filter { it.content.id != contentId }.toImmutableList()
+
+ _uiState.update { state ->
+ state.copy(bookmarkContents = state.bookmarkContents.copy(items = updated))
+ }
+
+ true
+ }
+
+ if (!removed) return@launch
+
+ bookmarkRepository
+ .deleteBookmark(contentId)
+ .onSuccess {
+ _uiState.update { state ->
+ state.copy(totalBookmarkCount = state.totalBookmarkCount?.minus(1))
+ }
+ _uiEffect.send(BookmarkContentListUiEffect.BookmarkRemovedList)
+ }.onFailure {
+ _uiEffect.send(BookmarkContentListUiEffect.ShowBookmarkRemoveFailedList)
+ }
+ } finally {
+ // 중복 API 호출 방지 리소스 정리
+ removeBookmarkMutex.withLock { removingIds.remove(contentId) }
+ }
+ }
+ }
+
+ companion object {
+ private const val PAGE_SIZE = 20
+ }
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/bookmark/component/BookmarkContentListAppBar.kt b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/component/BookmarkContentListAppBar.kt
new file mode 100644
index 000000000..ccde624b6
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/component/BookmarkContentListAppBar.kt
@@ -0,0 +1,55 @@
+package com.on.turip.ui.compose.bookmark.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.on.turip.R
+import com.on.turip.ui.compose.designsystem.component.TuripAppBar
+import com.on.turip.ui.compose.designsystem.theme.TuripTheme
+
+@Composable
+fun BookmarkContentListAppBar(
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ TuripAppBar(
+ start = {
+ IconButton(
+ onClick = onBackClick,
+ modifier = Modifier.size(36.dp),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.ArrowBack,
+ contentDescription = stringResource(R.string.all_back_description),
+ )
+ }
+ },
+ center = {
+ Text(
+ text = stringResource(R.string.bookmark_content_title),
+ style = TuripTheme.typography.title1,
+ )
+ },
+ modifier = modifier,
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun BookmarkContentListAppBarPreview() {
+ TuripTheme {
+ BookmarkContentListAppBar(
+ onBackClick = {},
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/bookmark/component/BookmarkContentListItem.kt b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/component/BookmarkContentListItem.kt
new file mode 100644
index 000000000..e936334f1
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/compose/bookmark/component/BookmarkContentListItem.kt
@@ -0,0 +1,123 @@
+package com.on.turip.ui.compose.bookmark.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.tooling.preview.Preview
+import com.on.turip.domain.bookmark.BookmarkContent
+import com.on.turip.domain.content.Content
+import com.on.turip.domain.content.video.VideoData
+import com.on.turip.domain.creator.Creator
+import com.on.turip.domain.region.City
+import com.on.turip.domain.trip.TripDuration
+import com.on.turip.ui.common.component.bookmark.BookmarkContentMetaSection
+import com.on.turip.ui.common.component.bookmark.BookmarkContentTitleRow
+import com.on.turip.ui.common.component.content.ContentThumbnail
+import com.on.turip.ui.compose.designsystem.theme.TuripTheme
+
+@Composable
+fun BookmarkContentListItem(
+ content: BookmarkContent,
+ onContentClick: (contentId: Long) -> Unit,
+ onRemoveBookmark: (contentId: Long) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clip(TuripTheme.shape.container)
+ .clickable { onContentClick(content.content.id) }
+ .background(TuripTheme.colors.white)
+ .padding(TuripTheme.spacing.extraSmall),
+ ) {
+ ContentThumbnail(
+ imageUrl = content.content.videoData.url,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(TuripTheme.spacing.medium))
+
+ BookmarkContentTitleRow(
+ title = content.content.videoData.title,
+ trailing = {
+ BookmarkRegionChip(
+ regionName = content.content.city.name,
+ modifier = Modifier.padding(horizontal = TuripTheme.spacing.small),
+ )
+ },
+ )
+
+ Spacer(modifier = Modifier.height(TuripTheme.spacing.small))
+
+ BookmarkContentMetaSection(
+ item = content,
+ onRemoveBookmark = onRemoveBookmark,
+ )
+ }
+}
+
+@Composable
+private fun BookmarkRegionChip(
+ regionName: String,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .wrapContentSize()
+ .background(
+ color = TuripTheme.colors.chipBackground,
+ shape = TuripTheme.shape.chip,
+ )
+ .padding(
+ horizontal = TuripTheme.spacing.medium,
+ vertical = TuripTheme.spacing.extraSmall,
+ ),
+ ) {
+ Text(
+ text = regionName,
+ style = TuripTheme.typography.info1,
+ color = TuripTheme.colors.black,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun BookmarkContentListItemPreview() {
+ TuripTheme {
+ val content =
+ BookmarkContent(
+ content =
+ Content(
+ 1L,
+ Creator(1L, "채널명", ""),
+ VideoData("콘텐츠 제목이 길면 ...으로 표시되는 것을 확인 ㅇㅇㅇ", "thumbnail", "2026-02-12"),
+ City("대구"),
+ true,
+ ),
+ tripDuration = TripDuration(1, 2),
+ tripPlaceCount = 2,
+ )
+ BookmarkContentListItem(
+ content = content,
+ onContentClick = {},
+ onRemoveBookmark = {},
+ modifier =
+ Modifier
+ .padding(TuripTheme.spacing.large)
+ .background(TuripTheme.colors.white),
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/mypage/MyPageScreen.kt b/android/app/src/main/java/com/on/turip/ui/compose/mypage/MyPageScreen.kt
index e44b68c79..26e5203d8 100644
--- a/android/app/src/main/java/com/on/turip/ui/compose/mypage/MyPageScreen.kt
+++ b/android/app/src/main/java/com/on/turip/ui/compose/mypage/MyPageScreen.kt
@@ -19,20 +19,31 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.on.turip.R
+import com.on.turip.domain.bookmark.BookmarkContent
+import com.on.turip.domain.content.Content
+import com.on.turip.domain.content.video.VideoData
+import com.on.turip.domain.creator.Creator
+import com.on.turip.domain.region.City
+import com.on.turip.domain.trip.TripDuration
import com.on.turip.ui.common.error.toUiModel
import com.on.turip.ui.common.extensions.dismissAndExecute
import com.on.turip.ui.common.extensions.showSnackbarWithAction
import com.on.turip.ui.compose.designsystem.component.TuripDialog
import com.on.turip.ui.compose.designsystem.component.TuripSnackbar
import com.on.turip.ui.compose.designsystem.theme.TuripTheme
-import com.on.turip.ui.compose.mypage.component.BookmarkedContentSection
import com.on.turip.ui.compose.mypage.component.MyPageAppBar
+import com.on.turip.ui.compose.mypage.component.MyPageBookmarkContentSection
import com.on.turip.ui.compose.mypage.component.MyPageSettingsSection
import com.on.turip.ui.compose.mypage.component.ProfileSection
import com.on.turip.ui.compose.mypage.model.InquiryMail
+import com.on.turip.ui.compose.mypage.model.ProfileModel
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
@Composable
fun MyPageScreen(
@@ -200,7 +211,7 @@ private fun MyPageScreenContent(
)
}
item {
- BookmarkedContentSection(
+ MyPageBookmarkContentSection(
state = uiState.bookmarkContentState,
onViewAllContentClick = onNavigateToAllBookmarkContents,
onContentClick = onNavigateToContent,
@@ -223,12 +234,83 @@ private fun MyPageScreenContent(
}
}
+private class MyPageUiStatePreviewProvider : PreviewParameterProvider {
+ override val values: Sequence =
+ sequenceOf(
+ // 둘 다 성공
+ MyPageUiState.Idle.copy(
+ profileState =
+ MyPageSectionState.Success(
+ ProfileModel(
+ 1L,
+ "닉네임은 최대 2줄 까지 가능하도록 보여지고 있어요 닉네임은 최대 2줄 까지 가능하도록 보여지고 있어요",
+ null,
+ ),
+ ),
+ bookmarkContentState = MyPageSectionState.Success(previewBookmarkContents()),
+ ),
+ // 프로필 & 북마크 에러
+ MyPageUiState.Idle.copy(
+ profileState = MyPageSectionState.Error,
+ bookmarkContentState = MyPageSectionState.Error,
+ ),
+ // 북마크 로딩
+ MyPageUiState.Idle.copy(
+ bookmarkContentState = MyPageSectionState.Loading,
+ ),
+ // 로그아웃 다이얼로그
+ MyPageUiState.Idle.copy(dialogState = MyPageDialogState.LogoutRequired),
+ // 회원 탈퇴 다이얼로그
+ MyPageUiState.Idle.copy(dialogState = MyPageDialogState.ConfirmWithdraw),
+ )
+}
+
+private fun previewBookmarkContents(): ImmutableList =
+ persistentListOf(
+ BookmarkContent(
+ content =
+ Content(
+ id = 1L,
+ creator = Creator(1L, "채널명", ""),
+ videoData =
+ VideoData(
+ title = "콘텐츠 제목이 길면 말줄임 처리되는지 확인합니다",
+ url = "thumbnail",
+ uploadedDate = "2026-02-23",
+ ),
+ city = City("대구"),
+ isBookmarked = true,
+ ),
+ tripDuration = TripDuration(1, 2),
+ tripPlaceCount = 3,
+ ),
+ BookmarkContent(
+ content =
+ Content(
+ id = 2L,
+ creator = Creator(2L, "긴 채널명 긴 채널명 긴 채널명", ""),
+ videoData =
+ VideoData(
+ title = "짧은 제목",
+ url = "thumbnail",
+ uploadedDate = "2026-02-10",
+ ),
+ city = City("제주"),
+ isBookmarked = true,
+ ),
+ tripDuration = TripDuration(0, 1),
+ tripPlaceCount = 5,
+ ),
+ )
+
@Preview(showBackground = true)
@Composable
-private fun MyPageScreenPreview() {
+private fun MyPageScreenPreview(
+ @PreviewParameter(MyPageUiStatePreviewProvider::class) uiState: MyPageUiState,
+) {
TuripTheme {
MyPageScreenContent(
- uiState = MyPageUiState.Idle,
+ uiState = uiState,
snackbarHostState = remember { SnackbarHostState() },
onNavigateToAllBookmarkContents = {},
onNavigateToContent = {},
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/mypage/MyPageViewModel.kt b/android/app/src/main/java/com/on/turip/ui/compose/mypage/MyPageViewModel.kt
index d4956d745..c686c5826 100644
--- a/android/app/src/main/java/com/on/turip/ui/compose/mypage/MyPageViewModel.kt
+++ b/android/app/src/main/java/com/on/turip/ui/compose/mypage/MyPageViewModel.kt
@@ -5,10 +5,12 @@ import androidx.lifecycle.viewModelScope
import com.on.turip.core.result.ErrorType
import com.on.turip.core.result.onFailure
import com.on.turip.core.result.onSuccess
-import com.on.turip.domain.accounts.Account
-import com.on.turip.domain.accounts.AccountRepository
-import com.on.turip.domain.bookmark.PagedBookmarkContents
+import com.on.turip.domain.account.Account
+import com.on.turip.domain.account.AccountRepository
+import com.on.turip.domain.bookmark.BookmarkContent
import com.on.turip.domain.bookmark.repository.BookmarkRepository
+import com.on.turip.domain.common.paging.Cursor
+import com.on.turip.domain.common.paging.Page
import com.on.turip.domain.login.MemberRepository
import com.on.turip.domain.setting.PrivacyPolicy
import com.on.turip.domain.userstorage.repository.UserStorageRepository
@@ -72,12 +74,13 @@ class MyPageViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(bookmarkContentState = MyPageSectionState.Loading) }
+ val cursor = Cursor(size = 10, lastId = null)
bookmarkRepository
- .loadBookmarks(10, 0L)
- .onSuccess { result: PagedBookmarkContents ->
+ .loadBookmarks(cursor)
+ .onSuccess { result: Page ->
Timber.d("마이페이지 북마크 목록 조회 성공")
_uiState.update {
- it.copy(bookmarkContentState = MyPageSectionState.Success(result.bookmarkContents.toImmutableList()))
+ it.copy(bookmarkContentState = MyPageSectionState.Success(result.items.toImmutableList()))
}
}.onFailure {
Timber.e("마이페이지 북마크 목록 조회 에러 발생")
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/mypage/component/MyPageBookmarkContentItem.kt b/android/app/src/main/java/com/on/turip/ui/compose/mypage/component/MyPageBookmarkContentItem.kt
new file mode 100644
index 000000000..9d663d3e3
--- /dev/null
+++ b/android/app/src/main/java/com/on/turip/ui/compose/mypage/component/MyPageBookmarkContentItem.kt
@@ -0,0 +1,89 @@
+package com.on.turip.ui.compose.mypage.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.on.turip.domain.bookmark.BookmarkContent
+import com.on.turip.domain.content.Content
+import com.on.turip.domain.content.video.VideoData
+import com.on.turip.domain.creator.Creator
+import com.on.turip.domain.region.City
+import com.on.turip.domain.trip.TripDuration
+import com.on.turip.ui.common.component.bookmark.BookmarkContentMetaSection
+import com.on.turip.ui.common.component.bookmark.BookmarkContentTitleRow
+import com.on.turip.ui.common.component.content.ContentThumbnail
+import com.on.turip.ui.compose.designsystem.theme.TuripTheme
+
+@Composable
+fun MyPageBookmarkContentItem(
+ item: BookmarkContent,
+ onContentClick: (contentId: Long) -> Unit,
+ onRemoveBookmark: (contentId: Long) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .border(1.dp, TuripTheme.colors.border, TuripTheme.shape.container)
+ .clip(TuripTheme.shape.container)
+ .clickable { onContentClick(item.content.id) }
+ .background(TuripTheme.colors.white)
+ .padding(TuripTheme.spacing.extraSmall),
+ ) {
+ ContentThumbnail(
+ imageUrl = item.content.videoData.url,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(TuripTheme.spacing.medium))
+
+ BookmarkContentTitleRow(title = item.content.videoData.title)
+
+ Spacer(modifier = Modifier.height(TuripTheme.spacing.small))
+
+ BookmarkContentMetaSection(
+ item = item,
+ onRemoveBookmark = onRemoveBookmark,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MyPageBookmarkContentItemPreview() {
+ val content =
+ BookmarkContent(
+ content =
+ Content(
+ 1L,
+ Creator(1L, "채널명", ""),
+ VideoData("콘텐츠 제목이 길면 ...으로 표시되는 것을 확인 ㅇㅇㅇ", "thumbnail", "2026-02-23"),
+ City(""),
+ true,
+ ),
+ tripDuration = TripDuration(1, 2),
+ tripPlaceCount = 2,
+ )
+ TuripTheme {
+ MyPageBookmarkContentItem(
+ item = content,
+ onContentClick = {},
+ onRemoveBookmark = {},
+ modifier =
+ Modifier
+ .width(280.dp)
+ .padding(TuripTheme.spacing.large),
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/mypage/component/BookmarkedContentSection.kt b/android/app/src/main/java/com/on/turip/ui/compose/mypage/component/MyPageBookmarkContentSection.kt
similarity index 90%
rename from android/app/src/main/java/com/on/turip/ui/compose/mypage/component/BookmarkedContentSection.kt
rename to android/app/src/main/java/com/on/turip/ui/compose/mypage/component/MyPageBookmarkContentSection.kt
index 2a336fce2..117f12d5c 100644
--- a/android/app/src/main/java/com/on/turip/ui/compose/mypage/component/BookmarkedContentSection.kt
+++ b/android/app/src/main/java/com/on/turip/ui/compose/mypage/component/MyPageBookmarkContentSection.kt
@@ -44,7 +44,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
-fun BookmarkedContentSection(
+fun MyPageBookmarkContentSection(
state: MyPageSectionState>,
onViewAllContentClick: () -> Unit,
onContentClick: (contentId: Long) -> Unit,
@@ -81,7 +81,7 @@ fun BookmarkedContentSection(
modifier = Modifier.padding(start = TuripTheme.spacing.large),
) {
items(items = state.data, key = { it.content.id }) {
- BookmarkedContentItem(
+ MyPageBookmarkContentItem(
item = it,
onContentClick = onContentClick,
onRemoveBookmark = onRemoveBookmark,
@@ -228,24 +228,24 @@ private fun EmptyBookmarkedContent() {
@Preview(showBackground = true, name = "북마크한 컨텐츠 없음 ")
@Composable
-private fun BookmarkedContentSectionEmptyPreview() {
+private fun MyPageBookmarkContentSectionEmptyPreview() {
TuripTheme {
- BookmarkedContentSection(
+ MyPageBookmarkContentSection(
state = MyPageSectionState.Success(persistentListOf()),
onViewAllContentClick = {},
onContentClick = {},
onRemoveBookmark = {},
onRetry = {},
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.padding(TuripTheme.spacing.large),
)
}
}
@Preview(showBackground = true, name = "컨텐츠 존재")
@Composable
-private fun BookmarkedContentSectionWithItemsPreview() {
+private fun MyPageBookmarkContentSectionWithItemsPreview() {
TuripTheme {
- BookmarkedContentSection(
+ MyPageBookmarkContentSection(
state =
MyPageSectionState.Success(
persistentListOf(
@@ -257,7 +257,7 @@ private fun BookmarkedContentSectionWithItemsPreview() {
VideoData(
"콘텐츠 제목이 길면 ...으로 표시되는 것을 확인 ㅇㅇㅇ",
"thumbnail",
- "1박 2일",
+ "2026-01-02",
),
City(""),
true,
@@ -271,37 +271,46 @@ private fun BookmarkedContentSectionWithItemsPreview() {
onContentClick = {},
onRemoveBookmark = {},
onRetry = {},
- modifier = Modifier.fillMaxWidth(),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(TuripTheme.spacing.large),
)
}
}
@Preview(showBackground = true, name = "로딩 중")
@Composable
-private fun BookmarkedContentSectionLoadingPreview() {
+private fun MyPageBookmarkContentSectionLoadingPreview() {
TuripTheme {
- BookmarkedContentSection(
+ MyPageBookmarkContentSection(
state = MyPageSectionState.Loading,
onViewAllContentClick = {},
onContentClick = {},
onRemoveBookmark = {},
onRetry = {},
- modifier = Modifier.fillMaxWidth(),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(TuripTheme.spacing.large),
)
}
}
@Preview(showBackground = true, name = "에러")
@Composable
-private fun BookmarkedContentSectionErrorPreview() {
+private fun MyPageBookmarkContentSectionErrorPreview() {
TuripTheme {
- BookmarkedContentSection(
+ MyPageBookmarkContentSection(
state = MyPageSectionState.Error,
onViewAllContentClick = {},
onContentClick = {},
onRemoveBookmark = {},
onRetry = {},
- modifier = Modifier.fillMaxWidth(),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(TuripTheme.spacing.large),
)
}
}
diff --git a/android/app/src/main/java/com/on/turip/ui/compose/mypage/util/MyPageMapper.kt b/android/app/src/main/java/com/on/turip/ui/compose/mypage/util/MyPageMapper.kt
index 9bfa76204..74c8e5085 100644
--- a/android/app/src/main/java/com/on/turip/ui/compose/mypage/util/MyPageMapper.kt
+++ b/android/app/src/main/java/com/on/turip/ui/compose/mypage/util/MyPageMapper.kt
@@ -1,6 +1,6 @@
package com.on.turip.ui.compose.mypage.util
-import com.on.turip.domain.accounts.Account
+import com.on.turip.domain.account.Account
import com.on.turip.ui.compose.mypage.model.ProfileModel
fun Account.toUiModel(): ProfileModel =
diff --git a/android/app/src/main/java/com/on/turip/ui/main/MainActivity.kt b/android/app/src/main/java/com/on/turip/ui/main/MainActivity.kt
index 8ee860e24..6fbc7cd97 100644
--- a/android/app/src/main/java/com/on/turip/ui/main/MainActivity.kt
+++ b/android/app/src/main/java/com/on/turip/ui/main/MainActivity.kt
@@ -16,9 +16,8 @@ import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : BaseActivity() {
- override val binding: ActivityMainBinding by lazy {
- ActivityMainBinding.inflate(layoutInflater)
- }
+ override val binding: ActivityMainBinding by lazy { ActivityMainBinding.inflate(layoutInflater) }
+
private var backPressedTime: Long = 0L
override fun onCreate(savedInstanceState: Bundle?) {
diff --git a/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentFragment.kt b/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentFragment.kt
index 352cccfff..93e2144dd 100644
--- a/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentFragment.kt
+++ b/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentFragment.kt
@@ -1,54 +1,15 @@
package com.on.turip.ui.main.bookmarks
-import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.fragment.app.viewModels
-import com.google.android.material.snackbar.Snackbar
-import com.on.turip.R
import com.on.turip.databinding.FragmentFavoriteContentBinding
-import com.on.turip.domain.bookmark.BookmarkContent
-import com.on.turip.ui.common.ItemDividerDecoration
import com.on.turip.ui.common.base.BaseFragment
-import com.on.turip.ui.common.error.ErrorUiModel
-import com.on.turip.ui.common.error.ErrorUiState
-import com.on.turip.ui.common.error.toUiModel
-import com.on.turip.ui.common.extensions.collectOnStarted
-import com.on.turip.ui.login.LoginActivity
-import com.on.turip.ui.trip.TripDetailActivity
import dagger.hilt.android.AndroidEntryPoint
-import timber.log.Timber
@AndroidEntryPoint
class BookmarkContentFragment : BaseFragment() {
- private val viewModel: BookmarkContentViewModel by viewModels()
-
- private val favoriteContentAdapter: FavoriteContentAdapter by lazy {
- FavoriteContentAdapter(
- object : FavoriteContentViewHolder.FavoriteContentListener {
- override fun onFavoriteClick(
- contentId: Long,
- isFavorite: Boolean,
- ) {
- Timber.d("컨텐츠 목록의 북마크 버튼을 클릭(contentId=$contentId)\n업데이트 된 북마크 상태 =${!isFavorite}")
- viewModel.updateBookmark(contentId, isFavorite)
- }
-
- override fun onFavoriteItemClick(contentId: Long) {
- Timber.d("컨텐츠 목록의 아이템 클릭(contentId=$contentId)")
- val intent: Intent =
- TripDetailActivity.newIntent(
- context = requireContext(),
- contentId = contentId,
- )
- startActivity(intent)
- }
- },
- )
- }
-
override fun inflateBinding(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -59,73 +20,7 @@ class BookmarkContentFragment : BaseFragment() {
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)
- setupAdapters()
- setupObservers()
- }
-
- private fun setupAdapters() {
- binding.rvFavoriteContentContents.apply {
- adapter = favoriteContentAdapter
- addItemDecoration(
- ItemDividerDecoration(
- height = 1,
- color = requireContext().getColor(R.color.gray_100_f0f0ee),
- ),
- )
- }
- }
-
- private fun setupObservers() {
- collectOnStarted(viewModel.uiState) { uiState: BookmarkContentUiState ->
- if (uiState.isLoading) showLoading()
- when {
- uiState.errorUiState != ErrorUiState.None -> showErrorView(uiState.errorUiState)
- uiState.isEmpty -> showEmptyView()
- else -> showContents(uiState.bookmarkContents)
- }
- }
-
- collectOnStarted(viewModel.uiEffect) { uiEffect: BookmarkContentUiEffect ->
- when (uiEffect) {
- BookmarkContentUiEffect.NavigateToLogin -> {
- navigateToLoginScreen()
- }
-
- is BookmarkContentUiEffect.ShowError -> {
- val uiModel: ErrorUiModel =
- uiEffect.errorUiState.toUiModel() ?: return@collectOnStarted
- view?.let { view: View ->
- Snackbar
- .make(view, uiModel.titleRes, Snackbar.LENGTH_INDEFINITE)
- .apply {
- setAction(uiModel.retryTextRes) {
- viewModel.handleErrorRetryRequest(uiEffect.action)
- }
- }.show()
- }
- }
- }
- }
- }
-
- private fun showLoading() {
- binding.pbFavoriteContentLoading.visibility = View.VISIBLE
- binding.clFavoriteContentEmpty.visibility = View.GONE
- binding.clFavoriteContentNotEmpty.visibility = View.GONE
- binding.customErrorView.visibility = View.GONE
- }
-
- private fun showErrorView(errorUiState: ErrorUiState) {
- binding.customErrorView.visibility = View.VISIBLE
- binding.pbFavoriteContentLoading.visibility = View.GONE
- binding.clFavoriteContentEmpty.visibility = View.GONE
- binding.clFavoriteContentNotEmpty.visibility = View.GONE
-
- binding.customErrorView.apply {
- visibility = View.VISIBLE
- showErrorView(errorUiState)
- setOnRetryClickListener { viewModel.loadBookmarkContents() }
- }
+ showEmptyView()
}
private fun showEmptyView() {
@@ -135,36 +30,6 @@ class BookmarkContentFragment : BaseFragment() {
binding.clFavoriteContentNotEmpty.visibility = View.GONE
}
- private fun showContents(bookmarkContents: List) {
- binding.customErrorView.visibility = View.GONE
- binding.pbFavoriteContentLoading.visibility = View.GONE
-
- binding.clFavoriteContentNotEmpty.visibility = View.VISIBLE
- binding.clFavoriteContentEmpty.visibility = View.GONE
- favoriteContentAdapter.submitList(bookmarkContents)
- }
-
- private fun navigateToLoginScreen() {
- val intent: Intent =
- LoginActivity
- .newIntent(requireActivity())
- .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK }
- startActivity(intent)
- requireActivity().finish()
- }
-
- override fun onResume() {
- super.onResume()
- viewModel.loadBookmarkContents()
- }
-
- override fun onHiddenChanged(hidden: Boolean) {
- super.onHiddenChanged(hidden)
- if (!hidden) {
- viewModel.loadBookmarkContents()
- }
- }
-
companion object {
fun instance(): BookmarkContentFragment = BookmarkContentFragment()
}
diff --git a/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentUiEffect.kt b/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentUiEffect.kt
deleted file mode 100644
index f1ea4e027..000000000
--- a/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentUiEffect.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.on.turip.ui.main.bookmarks
-
-import com.on.turip.ui.common.error.ErrorUiState
-
-sealed interface BookmarkContentUiEffect {
- data object NavigateToLogin : BookmarkContentUiEffect
-
- data class ShowError(
- val errorUiState: ErrorUiState,
- val action: BookmarkContentRetryAction,
- ) : BookmarkContentUiEffect
-}
-
-sealed interface BookmarkContentRetryAction {
- data class UpdateBookmark(
- val contentId: Long,
- val isBookmarked: Boolean,
- ) : BookmarkContentRetryAction
-}
diff --git a/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentUiState.kt b/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentUiState.kt
deleted file mode 100644
index bfcc23228..000000000
--- a/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentUiState.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.on.turip.ui.main.bookmarks
-
-import com.on.turip.domain.bookmark.BookmarkContent
-import com.on.turip.ui.common.error.ErrorUiState
-
-data class BookmarkContentUiState(
- val isLoading: Boolean,
- val bookmarkContents: List,
- val errorUiState: ErrorUiState,
-) {
- val isEmpty: Boolean
- get() = bookmarkContents.isEmpty() && this != Idle
-
- companion object {
- val Idle: BookmarkContentUiState =
- BookmarkContentUiState(
- isLoading = true,
- bookmarkContents = emptyList(),
- errorUiState = ErrorUiState.None,
- )
- }
-}
diff --git a/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentViewModel.kt b/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentViewModel.kt
deleted file mode 100644
index 018ff3d71..000000000
--- a/android/app/src/main/java/com/on/turip/ui/main/bookmarks/BookmarkContentViewModel.kt
+++ /dev/null
@@ -1,150 +0,0 @@
-package com.on.turip.ui.main.bookmarks
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.on.turip.core.result.ErrorType
-import com.on.turip.core.result.onFailure
-import com.on.turip.core.result.onSuccess
-import com.on.turip.domain.bookmark.PagedBookmarkContents
-import com.on.turip.domain.bookmark.repository.BookmarkRepository
-import com.on.turip.domain.bookmark.usecase.UpdateBookmarkUseCase
-import com.on.turip.ui.common.error.ErrorUiState
-import com.on.turip.ui.common.error.UiError
-import com.on.turip.ui.common.error.toUiError
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.receiveAsFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import timber.log.Timber
-import javax.inject.Inject
-
-@HiltViewModel
-class BookmarkContentViewModel @Inject constructor(
- private val bookmarkRepository: BookmarkRepository,
- private val updateBookmarkUseCase: UpdateBookmarkUseCase,
-) : ViewModel() {
- private val _uiState: MutableStateFlow =
- MutableStateFlow(BookmarkContentUiState.Idle)
- val uiState: StateFlow = _uiState.asStateFlow()
-
- private val _uiEffect: Channel = Channel(Channel.BUFFERED)
- val uiEffect: Flow = _uiEffect.receiveAsFlow()
-
- init {
- loadBookmarkContents()
- }
-
- fun loadBookmarkContents() {
- viewModelScope.launch {
- _uiState.update { it.copy(isLoading = true) }
-
- bookmarkRepository
- .loadBookmarks(10, 0L)
- .onSuccess { result: PagedBookmarkContents ->
- Timber.d("북마크 목록 조회 성공")
- _uiState.update {
- it.copy(
- isLoading = false,
- bookmarkContents = result.bookmarkContents,
- errorUiState = ErrorUiState.None,
- )
- }
- }.onFailure { errorType: ErrorType ->
- Timber.e("북마크 목록 조회 에러 발생")
- val uiError: UiError = errorType.toUiError()
- if (uiError is UiError.Global) {
- when (uiError) {
- UiError.Global.Network -> {
- _uiState.update {
- it.copy(isLoading = false, errorUiState = ErrorUiState.Network)
- }
- }
-
- UiError.Global.Server -> {
- _uiState.update {
- it.copy(isLoading = false, errorUiState = ErrorUiState.Server)
- }
- }
-
- UiError.Global.TokenExpired -> {
- _uiState.update { it.copy(isLoading = false) }
- _uiEffect.send(BookmarkContentUiEffect.NavigateToLogin)
- }
- }
- }
- }
- }
- }
-
- fun updateBookmark(
- contentId: Long,
- isBookmarked: Boolean,
- ) {
- val updatedBookmark: Boolean = !isBookmarked
-
- viewModelScope.launch {
- updateBookmarkUseCase(updatedBookmark, contentId)
- .onSuccess {
- Timber.d("북마크 목록 화면, 북마크 클릭(contentId=$contentId, updatedBookmark = $updatedBookmark")
-
- _uiState.update { state: BookmarkContentUiState ->
- state.copy(
- isLoading = false,
- bookmarkContents = state.bookmarkContents.filter { it.content.id != contentId },
- errorUiState = ErrorUiState.None,
- )
- }
- }.onFailure { errorType: ErrorType ->
- Timber.e("북마크 목록 화면, 북마크 클릭 실패(contentId=$contentId, originBookmark = $isBookmarked)")
- _uiState.update { it.copy(isLoading = false) }
- val uiError: UiError = errorType.toUiError()
- if (uiError is UiError.Global) {
- when (uiError) {
- UiError.Global.Network -> {
- _uiEffect.send(
- BookmarkContentUiEffect.ShowError(
- errorUiState = ErrorUiState.Network,
- action =
- BookmarkContentRetryAction.UpdateBookmark(
- contentId = contentId,
- isBookmarked = isBookmarked,
- ),
- ),
- )
- }
-
- UiError.Global.Server -> {
- _uiEffect.send(
- BookmarkContentUiEffect.ShowError(
- errorUiState = ErrorUiState.Server,
- action =
- BookmarkContentRetryAction.UpdateBookmark(
- contentId = contentId,
- isBookmarked = isBookmarked,
- ),
- ),
- )
- }
-
- UiError.Global.TokenExpired -> {
- _uiEffect.send(BookmarkContentUiEffect.NavigateToLogin)
- }
- }
- }
- }
- }
- }
-
- fun handleErrorRetryRequest(action: BookmarkContentRetryAction) {
- when (action) {
- is BookmarkContentRetryAction.UpdateBookmark -> {
- updateBookmark(action.contentId, action.isBookmarked)
- }
- }
- }
-}
diff --git a/android/app/src/main/java/com/on/turip/ui/mypage/MyPageActivity.kt b/android/app/src/main/java/com/on/turip/ui/mypage/MyPageActivity.kt
index 209a23d73..5a30fc429 100644
--- a/android/app/src/main/java/com/on/turip/ui/mypage/MyPageActivity.kt
+++ b/android/app/src/main/java/com/on/turip/ui/mypage/MyPageActivity.kt
@@ -6,12 +6,17 @@ import android.net.Uri
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import com.on.turip.R
+import com.on.turip.ui.bookmarks.BookmarkContentActivity
+import com.on.turip.ui.bookmarks.BookmarkContentActivity.Companion.EXTRA_BOOKMARK_CONTENT_HAS_BOOKMARK_CHANGES
import com.on.turip.ui.common.extensions.safeStartActivityWithToast
import com.on.turip.ui.compose.designsystem.theme.TuripTheme
import com.on.turip.ui.compose.mypage.MyPageScreen
+import com.on.turip.ui.compose.mypage.MyPageViewModel
import com.on.turip.ui.compose.mypage.model.InquiryMail
import com.on.turip.ui.login.LoginActivity
import com.on.turip.ui.trip.TripDetailActivity
@@ -20,6 +25,8 @@ import timber.log.Timber
@AndroidEntryPoint
class MyPageActivity : AppCompatActivity() {
+ private val viewModel: MyPageViewModel by viewModels()
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -27,8 +34,10 @@ class MyPageActivity : AppCompatActivity() {
setContent {
TuripTheme {
MyPageScreen(
- // 화면 구현 필요
- onNavigateToAllBookmarkContents = {},
+ onNavigateToAllBookmarkContents = {
+ val intent = BookmarkContentActivity.newIntent(this)
+ bookmarkContentLauncher.launch(intent)
+ },
onNavigateToContent = { contentId: Long ->
Timber.d("마이페이지 북마크 콘텐츠 클릭(contentId=$contentId)")
val intent: Intent =
@@ -40,9 +49,11 @@ class MyPageActivity : AppCompatActivity() {
"mailto:${InquiryMail.RECIPIENT}?subject=${Uri.encode(InquiryMail.TITLE)}&body=${
Uri.encode(mail.content)
}".toUri()
-
val intent: Intent = Intent(Intent.ACTION_SENDTO).apply { data = uri }
- startActivity(intent)
+ safeStartActivityWithToast(
+ intent = intent,
+ errorToastMessage = getString(R.string.all_snackbar_not_found_inquiry_url),
+ )
},
onNavigateToPrivacyPolicy = { url: String ->
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
@@ -60,11 +71,25 @@ class MyPageActivity : AppCompatActivity() {
startActivity(intent)
finish()
},
+ viewModel = viewModel,
)
}
}
}
+ private val bookmarkContentLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode != RESULT_OK) return@registerForActivityResult
+
+ val changed =
+ result.data?.getBooleanExtra(EXTRA_BOOKMARK_CONTENT_HAS_BOOKMARK_CHANGES, false)
+ ?: false
+
+ if (changed) {
+ viewModel.loadBookmarkContents(isRetry = false)
+ }
+ }
+
companion object {
fun newIntent(context: Context): Intent = Intent(context, MyPageActivity::class.java)
}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 4c7c5a4c7..c063d5cdb 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -21,6 +21,7 @@
지도 앱으로 이동에 문제가 발생 했어요
개인 정보 처리 방침 이동에 문제가 발생 했어요
영상 앱으로 이동에 문제가 발생 했어요
+ 문의하기 이동에 문제가 발생 했어요
공유 하기
마스코트
@@ -54,7 +55,7 @@
개인 정보 처리 방침
로그인
로그아웃
- 회원 탈퇴
+ 회원탈퇴
정말 로그아웃 하시겠습니까?
로그아웃
취소
@@ -70,8 +71,12 @@
저장한 콘텐츠들을 불러오지 못했어요.
+ 저장한 콘텐츠
+ 콘텐츠 %d개
컨텐츠를 저장 해보세요!
원하는 동선이 담긴\n여행 일정을 저장해 보세요.
+ 데이터를 불러올 수 없습니다. 잠시 후 재시도 해주세요
+ 콘텐츠 -
튜립에 장소를 추가 해보세요!