diff --git a/app/ios-combined/src/iosTest/kotlin/club/nito/ios/combined/EntryPointTest.kt b/app/ios-combined/src/iosTest/kotlin/club/nito/ios/combined/EntryPointTest.kt index 253e105f..482588c7 100644 --- a/app/ios-combined/src/iosTest/kotlin/club/nito/ios/combined/EntryPointTest.kt +++ b/app/ios-combined/src/iosTest/kotlin/club/nito/ios/combined/EntryPointTest.kt @@ -3,7 +3,7 @@ package club.nito.ios.combined import club.nito.core.data.AuthRepository import club.nito.core.data.ScheduleRepository import club.nito.core.domain.AuthStatusStreamUseCase -import club.nito.core.domain.FetchMyParticipantStatusUseCase +import club.nito.core.domain.MyParticipantStatusStreamUseCase import club.nito.core.domain.FetchParticipantScheduleByIdUseCase import club.nito.core.domain.GetParticipantScheduleListUseCase import club.nito.core.domain.GetRecentScheduleUseCase @@ -45,6 +45,6 @@ class EntryPointTest { assertNotNull(kmpEntryPoint.get()) assertNotNull(kmpEntryPoint.get()) assertNotNull(kmpEntryPoint.get()) - assertNotNull(kmpEntryPoint.get()) + assertNotNull(kmpEntryPoint.get()) } } diff --git a/app/ios/Modules/Sources/Schedule/ComposeScheduleDetailScreen.swift b/app/ios/Modules/Sources/Schedule/ComposeScheduleDetailScreen.swift index 260e40fe..7d22783c 100644 --- a/app/ios/Modules/Sources/Schedule/ComposeScheduleDetailScreen.swift +++ b/app/ios/Modules/Sources/Schedule/ComposeScheduleDetailScreen.swift @@ -14,13 +14,10 @@ public struct ComposeScheduleDetailScreen: UIViewControllerRepresentable { return ScheduleDetailScreen_iosKt.ScheduleDetailRouteViewController( id: scheduleId, stateMachine: ScheduleDetailStateMachine( - id: scheduleId, - fetchParticipantScheduleById: Container.shared.get( - type: FetchParticipantScheduleByIdUseCase.self - ), - fetchMyParticipantStatus: Container.shared.get( - type: FetchMyParticipantStatusUseCase.self - ), + scheduleId: scheduleId, + scheduleStream: Container.shared.get(type: ScheduleStreamUseCase.self), + scheduleParticipantsStream: Container.shared.get(type: ScheduleParticipantsStreamUseCase.self), + myParticipantStatusStream: Container.shared.get(type: MyParticipantStatusStreamUseCase.self), participate: Container.shared.get(type: ParticipateUseCase.self), userMessageStateHolder: Container.shared.get(type: UserMessageStateHolder.self), dateTimeFormatter: Container.shared.get(type: CommonNitoDateFormatter.self) diff --git a/core/data/src/commonMain/kotlin/club/nito/core/data/DefaultParticipantRepository.kt b/core/data/src/commonMain/kotlin/club/nito/core/data/DefaultParticipantRepository.kt index 5f50ddd2..fe332f1e 100644 --- a/core/data/src/commonMain/kotlin/club/nito/core/data/DefaultParticipantRepository.kt +++ b/core/data/src/commonMain/kotlin/club/nito/core/data/DefaultParticipantRepository.kt @@ -32,13 +32,29 @@ public class DefaultParticipantRepository( override suspend fun fetchParticipantStatus(scheduleId: ScheduleId, userId: String): ParticipantStatus = remoteDataSource.fetchParticipantStatus(scheduleId = scheduleId, userId = userId) - override suspend fun insertParticipate(declaration: ParticipantDeclaration): Participant { - val participant = remoteDataSource.insertParticipate(declaration = declaration) - dao.upsert(participant) - return participant - } + override fun participantStatusStream(scheduleId: ScheduleId, userId: String): Flow = + dao.participantStatusStream( + scheduleId = scheduleId, + userId = userId, + ) + + override suspend fun upsertLocalParticipate(participant: Participant): Unit = dao.upsert(participant) + + override suspend fun upsertParticipate(participant: Participant): Participant = remoteDataSource + .upsertParticipate(participant = participant) + .also { + dao.upsert(it) + } override suspend fun updateParticipate(declaration: ParticipantDeclaration): Participant { + dao.upsert( + entity = Participant( + scheduleId = declaration.scheduleId, + userId = declaration.userId, + status = declaration.status, + ), + ) + val participant = remoteDataSource.updateParticipate(declaration = declaration) dao.upsert(participant) return participant diff --git a/core/data/src/commonMain/kotlin/club/nito/core/data/ParticipantRepository.kt b/core/data/src/commonMain/kotlin/club/nito/core/data/ParticipantRepository.kt index 39fa9a90..c670ff57 100644 --- a/core/data/src/commonMain/kotlin/club/nito/core/data/ParticipantRepository.kt +++ b/core/data/src/commonMain/kotlin/club/nito/core/data/ParticipantRepository.kt @@ -49,12 +49,28 @@ public sealed interface ParticipantRepository { */ public suspend fun fetchParticipantStatus(scheduleId: ScheduleId, userId: String): ParticipantStatus + /** + * 該当の予定の対象のユーザーの参加情報を取得する + * + * @param scheduleId 参加情報を取得するスケジュールID + * @param userId 対象のユーザーID + * @return 参加情報 + */ + public fun participantStatusStream(scheduleId: ScheduleId, userId: String): Flow + /** * 該当スケジュールへの参加状況を追加する * - * @param declaration 参加表明データ + * @param participant 参加表明データ + */ + public suspend fun upsertLocalParticipate(participant: Participant) + + /** + * 該当スケジュールへの参加状況を追加する + * + * @param participant 参加表明データ */ - public suspend fun insertParticipate(declaration: ParticipantDeclaration): Participant + public suspend fun upsertParticipate(participant: Participant): Participant /** * 該当スケジュールへの参加状況を更新する diff --git a/core/domain/src/commonMain/kotlin/club/nito/core/domain/FetchMyParticipantStatusUseCase.kt b/core/domain/src/commonMain/kotlin/club/nito/core/domain/FetchMyParticipantStatusUseCase.kt deleted file mode 100644 index 459c022e..00000000 --- a/core/domain/src/commonMain/kotlin/club/nito/core/domain/FetchMyParticipantStatusUseCase.kt +++ /dev/null @@ -1,29 +0,0 @@ -package club.nito.core.domain - -import club.nito.core.data.AuthRepository -import club.nito.core.data.ParticipantRepository -import club.nito.core.model.FetchSingleContentResult -import club.nito.core.model.participant.ParticipantStatus -import club.nito.core.model.runFetchSingleContent -import club.nito.core.model.schedule.ScheduleId - -/** - * 自身の参加状況を取得するユースケース - */ -public sealed interface FetchMyParticipantStatusUseCase { - public suspend operator fun invoke(id: ScheduleId): FetchSingleContentResult -} - -public class FetchMyParticipantStatusExecutor( - private val authRepository: AuthRepository, - private val participantRepository: ParticipantRepository, -) : FetchMyParticipantStatusUseCase { - override suspend fun invoke(id: ScheduleId): FetchSingleContentResult = - runFetchSingleContent { - val currentUserId = authRepository.currentUser().id - participantRepository.fetchParticipantStatus( - scheduleId = id, - userId = currentUserId, - ) - } -} diff --git a/core/domain/src/commonMain/kotlin/club/nito/core/domain/MyParticipantStatusStreamUseCase.kt b/core/domain/src/commonMain/kotlin/club/nito/core/domain/MyParticipantStatusStreamUseCase.kt new file mode 100644 index 00000000..711fdcb2 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/club/nito/core/domain/MyParticipantStatusStreamUseCase.kt @@ -0,0 +1,30 @@ +package club.nito.core.domain + +import club.nito.core.data.AuthRepository +import club.nito.core.data.ParticipantRepository +import club.nito.core.model.participant.ParticipantStatus +import club.nito.core.model.schedule.ScheduleId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * 自身の参加状況を取得するユースケース + */ +public sealed interface MyParticipantStatusStreamUseCase { + public operator fun invoke(id: ScheduleId): Flow +} + +public class FetchMyParticipantStatusExecutor( + private val authRepository: AuthRepository, + private val participantRepository: ParticipantRepository, +) : MyParticipantStatusStreamUseCase { + override fun invoke(id: ScheduleId): Flow = flow { + val currentUserId = authRepository.currentUser().id + participantRepository.participantStatusStream( + scheduleId = id, + userId = currentUserId, + ).collect { + emit(it) + } + } +} diff --git a/core/domain/src/commonMain/kotlin/club/nito/core/domain/ParticipateUseCase.kt b/core/domain/src/commonMain/kotlin/club/nito/core/domain/ParticipateUseCase.kt index 45ba8a87..56f3ac38 100644 --- a/core/domain/src/commonMain/kotlin/club/nito/core/domain/ParticipateUseCase.kt +++ b/core/domain/src/commonMain/kotlin/club/nito/core/domain/ParticipateUseCase.kt @@ -4,44 +4,66 @@ import club.nito.core.data.AuthRepository import club.nito.core.data.ParticipantRepository import club.nito.core.model.ExecuteResult import club.nito.core.model.participant.Participant -import club.nito.core.model.participant.ParticipantDeclaration import club.nito.core.model.participant.ParticipantStatus -import club.nito.core.model.runExecuting +import club.nito.core.model.toNitoError +import kotlin.coroutines.cancellation.CancellationException /** * 参加表明するユースケース */ public sealed interface ParticipateUseCase { - public suspend operator fun invoke(scheduleId: String, status: ParticipantStatus): ExecuteResult + public suspend operator fun invoke( + scheduleId: String, + oldStatus: ParticipantStatus, + newStatus: ParticipantStatus, + ): ExecuteResult } public class ParticipateExecutor( private val authRepository: AuthRepository, private val participantRepository: ParticipantRepository, ) : ParticipateUseCase { - override suspend fun invoke(scheduleId: String, status: ParticipantStatus): ExecuteResult = runExecuting { + override suspend fun invoke( + scheduleId: String, + oldStatus: ParticipantStatus, + newStatus: ParticipantStatus, + ): ExecuteResult { val currentUserId = authRepository.currentUser().id - val exist = participantRepository.existParticipantByUserId( + + // NOTE: 失敗時の復元用キャッシュ + val cachedParticipant = Participant( scheduleId = scheduleId, userId = currentUserId, + status = oldStatus, ) - if (exist) { - participantRepository.updateParticipate( - declaration = ParticipantDeclaration( - scheduleId = scheduleId, - userId = currentUserId, - status = status, - ), - ) - } else { - participantRepository.insertParticipate( - declaration = ParticipantDeclaration( + // NOTE: 成功可否に関わらず一旦選択した状態を反映する + participantRepository.upsertLocalParticipate( + participant = Participant( + scheduleId = scheduleId, + userId = currentUserId, + status = newStatus, + ), + ) + + try { + val participant = participantRepository.upsertParticipate( + participant = Participant( scheduleId = scheduleId, userId = currentUserId, - status = status, + status = newStatus, ), ) + return ExecuteResult.Success(data = participant) + } catch (e: Exception) { + when (e) { + is CancellationException -> throw e + else -> { + // NOTE: 失敗時はキャッシュを復元する + participantRepository.upsertLocalParticipate(participant = cachedParticipant) + return ExecuteResult.Failure(error = e.toNitoError()) + } + } } } } diff --git a/core/domain/src/commonMain/kotlin/club/nito/core/domain/ScheduleParticipantsStreamUseCase.kt b/core/domain/src/commonMain/kotlin/club/nito/core/domain/ScheduleParticipantsStreamUseCase.kt new file mode 100644 index 00000000..57f2fe3e --- /dev/null +++ b/core/domain/src/commonMain/kotlin/club/nito/core/domain/ScheduleParticipantsStreamUseCase.kt @@ -0,0 +1,41 @@ +package club.nito.core.domain + +import club.nito.core.data.ParticipantRepository +import club.nito.core.model.FetchMultipleContentResult +import club.nito.core.model.participant.ParticipantUser +import club.nito.core.model.schedule.ScheduleId +import club.nito.core.model.toNitoError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlin.coroutines.cancellation.CancellationException + +/** + * 該当スケジュールの参加者情報のストリームを取得するユースケース + */ +public sealed interface ScheduleParticipantsStreamUseCase { + public operator fun invoke(id: ScheduleId): Flow> +} + +public class ScheduleParticipantsStreamExecutor( + private val participantRepository: ParticipantRepository, +) : ScheduleParticipantsStreamUseCase { + override fun invoke(id: ScheduleId): Flow> { + return participantRepository + .participantUsersStream(scheduleId = id) + .map { + if (it.isEmpty()) { + return@map FetchMultipleContentResult.NoContent + } + + FetchMultipleContentResult.Success(it) + } + .catch { e -> + if (e is CancellationException) { + throw e + } + + emit(FetchMultipleContentResult.Failure(e.toNitoError())) + } + } +} diff --git a/core/domain/src/commonMain/kotlin/club/nito/core/domain/ScheduleStreamUseCase.kt b/core/domain/src/commonMain/kotlin/club/nito/core/domain/ScheduleStreamUseCase.kt new file mode 100644 index 00000000..45dd8058 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/club/nito/core/domain/ScheduleStreamUseCase.kt @@ -0,0 +1,38 @@ +package club.nito.core.domain + +import club.nito.core.data.ScheduleRepository +import club.nito.core.model.FetchSingleContentResult +import club.nito.core.model.schedule.ScheduleId +import club.nito.core.model.schedule.ScheduleWithPlace +import club.nito.core.model.toNitoError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlin.coroutines.cancellation.CancellationException + +/** + * スケジュールを取得するユースケース + */ +public sealed interface ScheduleStreamUseCase { + public operator fun invoke(id: ScheduleId): Flow> +} + +public class ScheduleStreamExecutor( + private val scheduleRepository: ScheduleRepository, +) : ScheduleStreamUseCase { + override fun invoke(id: ScheduleId): Flow> { + return scheduleRepository + .scheduleWithPlaceStream(id = id) + .map { + it?.let { FetchSingleContentResult.Success(it) } + ?: FetchSingleContentResult.NoContent + } + .catch { e -> + if (e is CancellationException) { + throw e + } + + emit(FetchSingleContentResult.Failure(e.toNitoError())) + } + } +} diff --git a/core/domain/src/commonMain/kotlin/club/nito/core/domain/di/UseCaseModule.kt b/core/domain/src/commonMain/kotlin/club/nito/core/domain/di/UseCaseModule.kt index d8139d02..64561718 100644 --- a/core/domain/src/commonMain/kotlin/club/nito/core/domain/di/UseCaseModule.kt +++ b/core/domain/src/commonMain/kotlin/club/nito/core/domain/di/UseCaseModule.kt @@ -3,7 +3,7 @@ package club.nito.core.domain.di import club.nito.core.domain.AuthStatusStreamExecutor import club.nito.core.domain.AuthStatusStreamUseCase import club.nito.core.domain.FetchMyParticipantStatusExecutor -import club.nito.core.domain.FetchMyParticipantStatusUseCase +import club.nito.core.domain.MyParticipantStatusStreamUseCase import club.nito.core.domain.FetchParticipantScheduleByIdExecutor import club.nito.core.domain.FetchParticipantScheduleByIdUseCase import club.nito.core.domain.GetParticipantScheduleListExecutor @@ -18,6 +18,10 @@ import club.nito.core.domain.ModifyPasswordExecutor import club.nito.core.domain.ModifyPasswordUseCase import club.nito.core.domain.ParticipateExecutor import club.nito.core.domain.ParticipateUseCase +import club.nito.core.domain.ScheduleParticipantsStreamExecutor +import club.nito.core.domain.ScheduleParticipantsStreamUseCase +import club.nito.core.domain.ScheduleStreamExecutor +import club.nito.core.domain.ScheduleStreamUseCase import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind @@ -29,8 +33,10 @@ public val useCaseModule: Module = module { singleOf(::ModifyPasswordExecutor) bind ModifyPasswordUseCase::class singleOf(::LogoutExecutor) bind LogoutUseCase::class singleOf(::FetchParticipantScheduleByIdExecutor) bind FetchParticipantScheduleByIdUseCase::class + singleOf(::ScheduleParticipantsStreamExecutor) bind ScheduleParticipantsStreamUseCase::class + singleOf(::ScheduleStreamExecutor) bind ScheduleStreamUseCase::class singleOf(::GetRecentScheduleExecutor) bind GetRecentScheduleUseCase::class singleOf(::GetParticipantScheduleListExecutor) bind GetParticipantScheduleListUseCase::class singleOf(::ParticipateExecutor) bind ParticipateUseCase::class - singleOf(::FetchMyParticipantStatusExecutor) bind FetchMyParticipantStatusUseCase::class + singleOf(::FetchMyParticipantStatusExecutor) bind MyParticipantStatusStreamUseCase::class } diff --git a/core/network/src/commonMain/kotlin/club/nito/core/network/participation/FakeParticipantRemoteDataSource.kt b/core/network/src/commonMain/kotlin/club/nito/core/network/participation/FakeParticipantRemoteDataSource.kt index 469b54da..c18c6b32 100644 --- a/core/network/src/commonMain/kotlin/club/nito/core/network/participation/FakeParticipantRemoteDataSource.kt +++ b/core/network/src/commonMain/kotlin/club/nito/core/network/participation/FakeParticipantRemoteDataSource.kt @@ -36,12 +36,12 @@ public data object FakeParticipantRemoteDataSource : ParticipantRemoteDataSource return ParticipantStatus.ATTENDANCE } - override suspend fun insertParticipate(declaration: ParticipantDeclaration): Participant { + override suspend fun upsertParticipate(participant: Participant): Participant { delay(1000) return createFakeNetworkParticipant( - scheduleId = declaration.scheduleId, - userId = declaration.userId, - status = declaration.status.toNetworkModel(), + scheduleId = participant.scheduleId, + userId = participant.userId, + status = participant.status.toNetworkModel(), ).toParticipant() } diff --git a/core/network/src/commonMain/kotlin/club/nito/core/network/participation/ParticipantRemoteDataSource.kt b/core/network/src/commonMain/kotlin/club/nito/core/network/participation/ParticipantRemoteDataSource.kt index 6c9eb6c9..103241ad 100644 --- a/core/network/src/commonMain/kotlin/club/nito/core/network/participation/ParticipantRemoteDataSource.kt +++ b/core/network/src/commonMain/kotlin/club/nito/core/network/participation/ParticipantRemoteDataSource.kt @@ -43,9 +43,9 @@ public sealed interface ParticipantRemoteDataSource { /** * 該当スケジュールへの参加状況を追加する * - * @param declaration 参加表明データ + * @param participant 参加表明データ */ - public suspend fun insertParticipate(declaration: ParticipantDeclaration): Participant + public suspend fun upsertParticipate(participant: Participant): Participant /** * 該当スケジュールへの参加状況を更新する diff --git a/core/network/src/commonMain/kotlin/club/nito/core/network/participation/SupabaseParticipantRemoteDataSource.kt b/core/network/src/commonMain/kotlin/club/nito/core/network/participation/SupabaseParticipantRemoteDataSource.kt index 08957c68..ec3823e8 100644 --- a/core/network/src/commonMain/kotlin/club/nito/core/network/participation/SupabaseParticipantRemoteDataSource.kt +++ b/core/network/src/commonMain/kotlin/club/nito/core/network/participation/SupabaseParticipantRemoteDataSource.kt @@ -75,9 +75,9 @@ public class SupabaseParticipantRemoteDataSource( .toParticipantStatus() } - override suspend fun insertParticipate(declaration: ParticipantDeclaration): Participant = networkService { - postgrest.insert( - value = declaration.toNetworkModel(), + override suspend fun upsertParticipate(participant: Participant): Participant = networkService { + postgrest.upsert( + value = participant.toNetworkModel(), ) { select() single() diff --git a/core/network/src/commonMain/kotlin/club/nito/core/network/participation/model/NetworkParticipant.kt b/core/network/src/commonMain/kotlin/club/nito/core/network/participation/model/NetworkParticipant.kt index 2cb4bdff..59ce266c 100644 --- a/core/network/src/commonMain/kotlin/club/nito/core/network/participation/model/NetworkParticipant.kt +++ b/core/network/src/commonMain/kotlin/club/nito/core/network/participation/model/NetworkParticipant.kt @@ -22,6 +22,15 @@ internal data class NetworkParticipant( */ internal fun NetworkParticipant?.toParticipantStatus(): ParticipantStatus = this?.status.toParticipantStatus() +/** + * ネットワークモデルに変換する + */ +internal fun Participant.toNetworkModel(): NetworkParticipant = NetworkParticipant( + scheduleId = scheduleId, + userId = userId, + status = status.toNetworkModel(), +) + internal fun createFakeNetworkParticipant( scheduleId: String = "bbe00d24-d840-460d-a127-f23f9e472cc6", userId: String = "bbe00d24-d840-460d-a127-f23f9e472cc6", diff --git a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailIntent.kt b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailIntent.kt index e10a3ebb..532e6247 100644 --- a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailIntent.kt +++ b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailIntent.kt @@ -1,31 +1,23 @@ package club.nito.feature.schedule.detail -import club.nito.core.domain.model.ParticipantSchedule -import club.nito.core.model.participant.ParticipantUser import club.nito.core.model.participant.ParticipantStatus +import club.nito.core.model.participant.ParticipantUser public sealed class ScheduleDetailIntent { public data class ClickParticipantUser(val user: ParticipantUser) : ScheduleDetailIntent() public sealed class ClickParticipantStatusChip : ScheduleDetailIntent() { - public abstract val schedule: ParticipantSchedule public abstract val status: ParticipantStatus - public data class Participate( - override val schedule: ParticipantSchedule, - ) : ClickParticipantStatusChip() { + public data object Participate : ClickParticipantStatusChip() { override val status: ParticipantStatus = ParticipantStatus.ATTENDANCE } - public data class Absent( - override val schedule: ParticipantSchedule, - ) : ClickParticipantStatusChip() { + public data object Absent : ClickParticipantStatusChip() { override val status: ParticipantStatus = ParticipantStatus.ABSENCE } - public data class Hold( - override val schedule: ParticipantSchedule, - ) : ClickParticipantStatusChip() { + public data object Hold : ClickParticipantStatusChip() { override val status: ParticipantStatus = ParticipantStatus.PENDING } } diff --git a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailScreen.kt b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailScreen.kt index 89db74ea..500cd8cf 100644 --- a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailScreen.kt +++ b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailScreen.kt @@ -51,11 +51,12 @@ import club.nito.core.common.NitoDateFormatter import club.nito.core.designsystem.component.CenterAlignedTopAppBar import club.nito.core.designsystem.component.Scaffold import club.nito.core.designsystem.component.Text -import club.nito.core.domain.model.ParticipantSchedule +import club.nito.core.model.FetchMultipleContentResult import club.nito.core.model.FetchSingleContentResult import club.nito.core.model.participant.ParticipantStatus import club.nito.core.model.participant.ParticipantUser import club.nito.core.model.schedule.ScheduleId +import club.nito.core.model.schedule.ScheduleWithPlace import club.nito.core.ui.ProfileImage import club.nito.core.ui.koinStateMachine import club.nito.core.ui.message.SnackbarMessageEffect @@ -106,7 +107,7 @@ private fun ScheduleDetailScreen( val layoutDirection = LocalLayoutDirection.current val localDensity = LocalDensity.current - val schedule = uiState.schedule + val schedule = uiState.scheduleWithPlace var bottomParticipateBarHeightDp by remember { mutableStateOf(0.dp) } @@ -128,36 +129,44 @@ private fun ScheduleDetailScreen( Box( modifier = Modifier.fillMaxSize(), ) { - when (schedule) { - FetchSingleContentResult.Loading -> CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), + val containerModifier = Modifier + .padding( + start = innerPadding.calculateStartPadding(layoutDirection), + end = innerPadding.calculateEndPadding(layoutDirection), ) + .fillMaxWidth() + .padding(horizontal = 16.dp) - FetchSingleContentResult.NoContent -> Text( - text = "スケジュールが見つかりませんでした", - modifier = Modifier.align(Alignment.Center), - ) - - is FetchSingleContentResult.Success -> { - val containerModifier = Modifier - .padding( - start = innerPadding.calculateStartPadding(layoutDirection), - end = innerPadding.calculateEndPadding(layoutDirection), + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + ) + .padding(bottom = bottomParticipateBarHeightDp) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(40.dp), + ) { + when (schedule) { + FetchSingleContentResult.Loading -> Box( + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), ) - .fillMaxWidth() - .padding(horizontal = 16.dp) + } - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding( - top = innerPadding.calculateTopPadding(), - bottom = innerPadding.calculateBottomPadding(), - ) - .padding(bottom = bottomParticipateBarHeightDp) - .padding(vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(40.dp), + FetchSingleContentResult.NoContent -> Box( + modifier = Modifier.fillMaxSize(), ) { + Text( + text = "スケジュールが見つかりませんでした", + modifier = Modifier.align(Alignment.Center), + ) + } + + is FetchSingleContentResult.Success -> { VenueSection( schedule = schedule.data, dateFormatter = uiState.dateFormatter, @@ -169,41 +178,45 @@ private fun ScheduleDetailScreen( dateFormatter = uiState.dateFormatter, modifier = containerModifier, ) + } - ParticipantSection( - schedule = schedule.data, - onClickParticipantUser = { dispatch(ScheduleDetailIntent.ClickParticipantUser(it)) }, - modifier = containerModifier, - contentPadding = PaddingValues(horizontal = 8.dp), + is FetchSingleContentResult.Failure -> Box( + modifier = Modifier.fillMaxSize(), + ) { + Text( + text = schedule.error?.message ?: "エラーが発生しました", + modifier = Modifier.align(Alignment.Center), ) } - - BottomParticipateBar( - myParticipantStatus = uiState.myParticipantStatus, - onClickParticipateChip = { - dispatch(ScheduleDetailIntent.ClickParticipantStatusChip.Participate(schedule.data)) - }, - onClickAbsentChip = { - dispatch(ScheduleDetailIntent.ClickParticipantStatusChip.Absent(schedule.data)) - }, - onClickHoldChip = { - dispatch(ScheduleDetailIntent.ClickParticipantStatusChip.Hold(schedule.data)) - }, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - bottomParticipateBarHeightDp = with(localDensity) { coordinates.size.height.toDp() } - }, - innerPadding = innerPadding, - ) } - is FetchSingleContentResult.Failure -> Text( - text = schedule.error?.message ?: "エラーが発生しました", - modifier = Modifier.align(Alignment.Center), + ParticipantSection( + users = uiState.users, + onClickParticipantUser = { dispatch(ScheduleDetailIntent.ClickParticipantUser(it)) }, + modifier = containerModifier, + contentPadding = PaddingValues(horizontal = 8.dp), ) } + + BottomParticipateBar( + status = uiState.myParticipantStatus, + onClickParticipateChip = { + dispatch(ScheduleDetailIntent.ClickParticipantStatusChip.Participate) + }, + onClickAbsentChip = { + dispatch(ScheduleDetailIntent.ClickParticipantStatusChip.Absent) + }, + onClickHoldChip = { + dispatch(ScheduleDetailIntent.ClickParticipantStatusChip.Hold) + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + bottomParticipateBarHeightDp = with(localDensity) { coordinates.size.height.toDp() } + }, + innerPadding = innerPadding, + ) } }, ) @@ -211,7 +224,7 @@ private fun ScheduleDetailScreen( @Composable private fun VenueSection( - schedule: ParticipantSchedule, + schedule: ScheduleWithPlace, dateFormatter: NitoDateFormatter, modifier: Modifier = Modifier, ) { @@ -275,7 +288,7 @@ private fun VenueSection( @Composable private fun MeetSection( - schedule: ParticipantSchedule, + schedule: ScheduleWithPlace, dateFormatter: NitoDateFormatter, modifier: Modifier = Modifier, ) { @@ -336,7 +349,7 @@ private fun MeetSection( @Composable private fun ParticipantSection( - schedule: ParticipantSchedule, + users: FetchMultipleContentResult, onClickParticipantUser: (ParticipantUser) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), @@ -351,39 +364,55 @@ private fun ParticipantSection( fontSize = 20.sp, ) - Column( - modifier = Modifier.fillMaxWidth(), - ) { - schedule.users.forEach { user -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onClickParticipantUser(user) } - .padding(contentPadding) - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - ProfileImage( - profile = user.profile, - modifier = Modifier.size(48.dp), - ) - Spacer(Modifier.width(16.dp)) - Text( - text = user.profile.displayName, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(16.dp)) - Text( - text = when (user.status) { - ParticipantStatus.NONE -> "" - ParticipantStatus.PENDING -> "未定" - ParticipantStatus.ATTENDANCE -> "参加" - ParticipantStatus.ABSENCE -> "欠席" - }, - ) + when (users) { + FetchMultipleContentResult.Loading -> CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + FetchMultipleContentResult.NoContent -> Text( + text = "参加者がいません", + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + is FetchMultipleContentResult.Success -> Column( + modifier = Modifier.fillMaxWidth(), + ) { + users.data.forEach { user -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClickParticipantUser(user) } + .padding(contentPadding) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + ProfileImage( + profile = user.profile, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = user.profile.displayName, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = when (user.status) { + ParticipantStatus.NONE -> "" + ParticipantStatus.PENDING -> "未定" + ParticipantStatus.ATTENDANCE -> "参加" + ParticipantStatus.ABSENCE -> "欠席" + }, + ) + } } } + + is FetchMultipleContentResult.Failure -> Text( + text = users.error?.message ?: "エラーが発生しました", + modifier = Modifier.align(Alignment.CenterHorizontally), + ) } } } @@ -391,7 +420,7 @@ private fun ParticipantSection( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun BottomParticipateBar( - myParticipantStatus: FetchSingleContentResult, + status: ParticipantStatus, onClickParticipateChip: () -> Unit, onClickAbsentChip: () -> Unit, onClickHoldChip: () -> Unit, @@ -430,63 +459,44 @@ private fun BottomParticipateBar( ), horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.End), ) { - when (myParticipantStatus) { - FetchSingleContentResult.Loading -> CircularProgressIndicator( - modifier = Modifier.align(Alignment.CenterVertically), - ) - - is FetchSingleContentResult.Success -> { - val status = myParticipantStatus.data - - val chipModifier = Modifier - .align(Alignment.CenterVertically) - .padding(all = 8.dp) - - InputChip( - selected = status == ParticipantStatus.ATTENDANCE, - onClick = onClickParticipateChip, - label = { - Text( - text = "参加", - modifier = chipModifier, - ) - }, - shape = CircleShape, - ) - - InputChip( - selected = status == ParticipantStatus.ABSENCE, - onClick = onClickAbsentChip, - label = { - Text( - text = "欠席", - modifier = chipModifier, - ) - }, - shape = CircleShape, + val chipModifier = Modifier + .align(Alignment.CenterVertically) + .padding(all = 8.dp) + + InputChip( + selected = status == ParticipantStatus.ATTENDANCE, + onClick = onClickParticipateChip, + label = { + Text( + text = "参加", + modifier = chipModifier, ) + }, + shape = CircleShape, + ) - InputChip( - selected = status == ParticipantStatus.PENDING, - onClick = onClickHoldChip, - label = { - Text( - text = "未定", - modifier = chipModifier, - ) - }, - shape = CircleShape, + InputChip( + selected = status == ParticipantStatus.ABSENCE, + onClick = onClickAbsentChip, + label = { + Text( + text = "欠席", + modifier = chipModifier, ) - } + }, + shape = CircleShape, + ) - is FetchSingleContentResult.Failure -> { + InputChip( + selected = status == ParticipantStatus.PENDING, + onClick = onClickHoldChip, + label = { Text( - text = myParticipantStatus.error?.message ?: "エラーが発生しました", - modifier = Modifier.align(Alignment.CenterVertically), + text = "未定", + modifier = chipModifier, ) - } - - else -> {} - } + }, + shape = CircleShape, + ) } } diff --git a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailScreenUiState.kt b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailScreenUiState.kt index fe40da89..390743ff 100644 --- a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailScreenUiState.kt +++ b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailScreenUiState.kt @@ -2,11 +2,16 @@ package club.nito.feature.schedule.detail import club.nito.core.common.NitoDateFormatter import club.nito.core.domain.model.ParticipantSchedule +import club.nito.core.model.FetchMultipleContentResult import club.nito.core.model.FetchSingleContentResult import club.nito.core.model.participant.ParticipantStatus +import club.nito.core.model.participant.ParticipantUser +import club.nito.core.model.schedule.ScheduleWithPlace public data class ScheduleDetailScreenUiState( val dateFormatter: NitoDateFormatter, - val schedule: FetchSingleContentResult, - val myParticipantStatus: FetchSingleContentResult, + val participantSchedule: FetchSingleContentResult, + val scheduleWithPlace: FetchSingleContentResult, + val users: FetchMultipleContentResult, + val myParticipantStatus: ParticipantStatus, ) diff --git a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailStateMachine.kt b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailStateMachine.kt index cccbb435..4f3ad807 100644 --- a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailStateMachine.kt +++ b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/detail/ScheduleDetailStateMachine.kt @@ -1,28 +1,36 @@ package club.nito.feature.schedule.detail import club.nito.core.common.NitoDateFormatter -import club.nito.core.domain.FetchMyParticipantStatusUseCase -import club.nito.core.domain.FetchParticipantScheduleByIdUseCase +import club.nito.core.domain.MyParticipantStatusStreamUseCase import club.nito.core.domain.ParticipateUseCase +import club.nito.core.domain.ScheduleParticipantsStreamUseCase +import club.nito.core.domain.ScheduleStreamUseCase import club.nito.core.domain.model.ParticipantSchedule +import club.nito.core.model.FetchMultipleContentResult import club.nito.core.model.FetchSingleContentResult import club.nito.core.model.handleResult import club.nito.core.model.participant.ParticipantStatus +import club.nito.core.model.participant.ParticipantUser import club.nito.core.model.schedule.ScheduleId +import club.nito.core.model.schedule.ScheduleWithPlace import club.nito.core.ui.StateMachine import club.nito.core.ui.buildUiState import club.nito.core.ui.message.UserMessageStateHolder +import club.nito.core.ui.stateMachineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import moe.tlaster.precompose.viewmodel.viewModelScope public class ScheduleDetailStateMachine( - id: ScheduleId, - fetchParticipantScheduleById: FetchParticipantScheduleByIdUseCase, - private val fetchMyParticipantStatus: FetchMyParticipantStatusUseCase, + private val scheduleId: ScheduleId, + scheduleStream: ScheduleStreamUseCase, + scheduleParticipantsStream: ScheduleParticipantsStreamUseCase, + myParticipantStatusStream: MyParticipantStatusStreamUseCase, private val participate: ParticipateUseCase, public val userMessageStateHolder: UserMessageStateHolder, private val dateTimeFormatter: NitoDateFormatter, @@ -30,16 +38,39 @@ public class ScheduleDetailStateMachine( UserMessageStateHolder by userMessageStateHolder { private val participantSchedule: MutableStateFlow> = MutableStateFlow(FetchSingleContentResult.Loading) - private val myParticipantStatus: MutableStateFlow> = - MutableStateFlow(FetchSingleContentResult.Loading) + + private val schedule: StateFlow> = + scheduleStream(id = scheduleId).stateIn( + scope = stateMachineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = FetchSingleContentResult.Loading, + ) + + private val participants: StateFlow> = + scheduleParticipantsStream(id = scheduleId).stateIn( + scope = stateMachineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = FetchMultipleContentResult.Loading, + ) + + private val myParticipantStatus: StateFlow = + myParticipantStatusStream(id = scheduleId).stateIn( + scope = stateMachineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ParticipantStatus.NONE, + ) public val uiState: StateFlow = buildUiState( + schedule, participantSchedule, + participants, myParticipantStatus, - ) { participantSchedule, myParticipantStatus -> + ) { schedule, participantSchedule, users, myParticipantStatus -> ScheduleDetailScreenUiState( dateFormatter = dateTimeFormatter, - schedule = participantSchedule, + participantSchedule = participantSchedule, + scheduleWithPlace = schedule, + users = users, myParticipantStatus = myParticipantStatus, ) } @@ -49,12 +80,6 @@ public class ScheduleDetailStateMachine( init { viewModelScope.launch { - launch { - participantSchedule.value = fetchParticipantScheduleById(id = id) - } - launch { - myParticipantStatus.value = fetchMyParticipantStatus(id = id) - } } } @@ -65,31 +90,25 @@ public class ScheduleDetailStateMachine( // TODO: ユーザー詳細画面へ遷移する } - is ScheduleDetailIntent.ClickParticipantStatusChip -> { - // NOTE: 失敗時の復元用キャッシュ - val cachedParticipantStatus = myParticipantStatus.value - // NOTE: 成功可否に関わらず一旦選択した状態を反映する - myParticipantStatus.value = FetchSingleContentResult.Success(intent.status) - - participate(intent.schedule.id, intent.status).handleResult( - onSuccess = { participant -> - myParticipantStatus.value = FetchSingleContentResult.Success(participant.status) - - val scheduledAt = dateTimeFormatter.formatDateTime(intent.schedule.scheduledAt) - val message = when (participant.status) { + is ScheduleDetailIntent.ClickParticipantStatusChip -> participate( + scheduleId = scheduleId, + oldStatus = myParticipantStatus.value, + newStatus = intent.status, + ).handleResult( + onSuccess = { participant -> + userMessageStateHolder.showMessage( + when (participant.status) { ParticipantStatus.NONE -> return@handleResult - ParticipantStatus.PENDING -> "$scheduledAt を未定にしました" - ParticipantStatus.ATTENDANCE -> "$scheduledAt に参加登録しました 🎉" - ParticipantStatus.ABSENCE -> "$scheduledAt を欠席にしました" + ParticipantStatus.PENDING -> "未定にしました" + ParticipantStatus.ATTENDANCE -> "参加登録しました 🎉" + ParticipantStatus.ABSENCE -> "欠席にしました" } - userMessageStateHolder.showMessage(message) - }, - onFailure = { - // NOTE: 失敗した場合はキャッシュを復元する - myParticipantStatus.value = cachedParticipantStatus - }, - ) - } + ) + }, + onFailure = { + userMessageStateHolder.showMessage(it?.message ?: "エラーが発生しました") + }, + ) } } } diff --git a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/di/ScheduleFeatureModule.kt b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/di/ScheduleFeatureModule.kt index 674104f3..33d81248 100644 --- a/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/di/ScheduleFeatureModule.kt +++ b/feature/schedule/src/commonMain/kotlin/club/nito/feature/schedule/di/ScheduleFeatureModule.kt @@ -14,11 +14,12 @@ public val scheduleFeatureModule: Module = module { dateFormatter = get(), ) } - factory { (id: ScheduleId) -> + factory { (scheduleId: ScheduleId) -> ScheduleDetailStateMachine( - id = id, - fetchParticipantScheduleById = get(), - fetchMyParticipantStatus = get(), + scheduleId = scheduleId, + scheduleStream = get(), + scheduleParticipantsStream = get(), + myParticipantStatusStream = get(), participate = get(), userMessageStateHolder = get(), dateTimeFormatter = get(),