diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1c527fe..002bb34 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -23,6 +23,7 @@ kotlin { implementation(libs.koin.android) implementation(libs.koin.androidx.compose) implementation(libs.androidx.navigation.compose) + implementation(libs.accompanist.swiperefresh) } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/main/MainView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/main/MainView.kt index 5d020cd..55da34e 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/main/MainView.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/main/MainView.kt @@ -1,5 +1,6 @@ package friends.mobile.main +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -9,10 +10,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -23,26 +29,39 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SegmentedButton import androidx.compose.material3.Text +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerLayoutType import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import friends.mobile.feature.main.domain.model.AvailabilityResult import friends.mobile.feature.main.domain.model.MainEvent -import friends.mobile.feature.main.presentation.MainEvent as MainEventAction +import friends.mobile.feature.main.presentation.MainViewAction as MainEventAction import friends.mobile.feature.main.presentation.MainViewModel import friends.mobile.feature.main.presentation.MainViewState +import kotlinx.coroutines.launch import java.text.SimpleDateFormat +import java.util.Calendar import java.util.Date import java.util.Locale @@ -55,13 +74,23 @@ fun MainView( ) { val viewModel: MainViewModel = viewModel() val state by viewModel.viewStates.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() var showDatePickerDialog by rememberSaveable { mutableStateOf(false) } + var showTimePickerDialog by rememberSaveable { mutableStateOf(false) } + var showBusyAlert by rememberSaveable { mutableStateOf(false) } + var isCheckingAvailability by rememberSaveable { mutableStateOf(false) } + var filterMode by rememberSaveable { mutableStateOf(FilterMode.ACTIVE) } val datePickerState = rememberDatePickerState( initialSelectedDateMillis = System.currentTimeMillis() ) + val timePickerState = rememberTimePickerState( + initialHour = 12, + initialMinute = 0 + ) val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val dateTimeFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) LaunchedEffect(Unit) { // Log screen open event for analytics @@ -72,19 +101,50 @@ fun MainView( val errorMessage = (state as? MainViewState.Error)?.message val isRefreshing = (state as? MainViewState.Content)?.isRefreshing ?: false - val upcomingEvents = - (state as? MainViewState.Content)?.upcomingEvents ?: emptyList() + val activeEvents = + (state as? MainViewState.Content)?.activeEvents ?: emptyList() + val pendingEvents = + (state as? MainViewState.Content)?.pendingEvents ?: emptyList() + + val eventsToDisplay = when (filterMode) { + FilterMode.ACTIVE -> activeEvents + FilterMode.PENDING -> pendingEvents + } + + // Detect unified empty state: both activeEvents AND pendingEvents are empty + val hasNoEventsAtAll = activeEvents.isEmpty() && pendingEvents.isEmpty() Scaffold( topBar = { TopAppBar( title = { Text("Home") }, actions = { - IconButton(onClick = onPendingEventsClick) { - Icon( - imageVector = Icons.Default.Notifications, - contentDescription = "Pending Invitations" - ) + BadgedBox( + badge = { + if (pendingEvents.isNotEmpty()) { + Badge( + modifier = Modifier + .background( + MaterialTheme.colorScheme.error, + shape = CircleShape + ) + .size(20.dp), + ) { + Text( + text = pendingEvents.size.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onError, + ) + } + } + } + ) { + IconButton(onClick = onPendingEventsClick) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = "Pending Invitations" + ) + } } } ) @@ -96,7 +156,7 @@ fun MainView( .padding(innerPadding), ) { when { - isLoading && upcomingEvents.isEmpty() -> { + isLoading && activeEvents.isEmpty() -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -105,7 +165,7 @@ fun MainView( } } - errorMessage != null && upcomingEvents.isEmpty() -> { + errorMessage != null && activeEvents.isEmpty() -> { Column( modifier = Modifier .fillMaxSize() @@ -129,70 +189,125 @@ fun MainView( } } + hasNoEventsAtAll -> { + // Unified empty state: no events at all + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "You have no events, create it now!", + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + ) + } + } + else -> { Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .fillMaxSize() + .padding(0.dp), ) { - if (upcomingEvents.isEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No events yet", - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - LazyColumn( + // Filter Toggle + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + ) { + SingleChoiceSegmentedButtonRow( modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), ) { - items( - upcomingEvents, - key = { it.id } - ) { event -> - EventCard( - event = event, - onClick = { - onEventDetailClick(event.id) - }, - ) + FilterMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + selected = filterMode == mode, + onClick = { filterMode = mode }, + shape = MaterialTheme.shapes.small, + modifier = Modifier.weight(1f), + ) { + val label = when (mode) { + FilterMode.ACTIVE -> "Active (${activeEvents.size})" + FilterMode.PENDING -> "Pending (${pendingEvents.size})" + } + Text(label) + } } } } - if (isRefreshing) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center, + // Events List with Pull-to-Refresh + val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) + SwipeRefresh( + state = swipeRefreshState, + onRefresh = { + viewModel.obtainEvent(MainEventAction.OnRefresh) + }, + modifier = Modifier.weight(1f), + ) { + Column( + modifier = Modifier.fillMaxSize(), ) { - CircularProgressIndicator() + if (eventsToDisplay.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + val emptyMessage = when (filterMode) { + FilterMode.ACTIVE -> "No active events yet" + FilterMode.PENDING -> "No pending invitations" + } + Text( + text = emptyMessage, + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + eventsToDisplay, + key = { it.id } + ) { event -> + EventCard( + event = event, + onClick = { + onEventDetailClick(event.id) + }, + isPending = filterMode == FilterMode.PENDING, + ) + } + } + } } } - } - Button( - onClick = { - showDatePickerDialog = true - }, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp), - ) { - Text("Create event") + // Create Event Button + Button( + onClick = { + showDatePickerDialog = true + }, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp), + ) { + Text("Create event") + } } } } } } - if (showDatePickerDialog) { + if (showDatePickerDialog && !showTimePickerDialog) { DatePickerDialog( onDismissRequest = { showDatePickerDialog = false @@ -200,15 +315,11 @@ fun MainView( confirmButton = { Button( onClick = { - val selectedDateMs = datePickerState.selectedDateMillis - if (selectedDateMs != null) { - val dateString = dateFormatter.format(Date(selectedDateMs)) - onCreateEventClick(dateString) - showDatePickerDialog = false - } + showDatePickerDialog = false + showTimePickerDialog = true }, ) { - Text("Create") + Text("Next") } }, dismissButton = { @@ -224,13 +335,107 @@ fun MainView( DatePicker(state = datePickerState) } } + + if (showTimePickerDialog) { + DateTimePickerDialog( + onDismissRequest = { + showTimePickerDialog = false + }, + confirmButton = { + Button( + onClick = { + val selectedDateMs = datePickerState.selectedDateMillis + if (selectedDateMs != null) { + val calendar = Calendar.getInstance().apply { + timeInMillis = selectedDateMs + set(Calendar.HOUR_OF_DAY, timePickerState.hour) + set(Calendar.MINUTE, timePickerState.minute) + } + val dateString = dateFormatter.format(calendar.time) + val dateTimeString = dateTimeFormatter.format(calendar.time) + + isCheckingAvailability = true + coroutineScope.launch { + val availabilityResult = viewModel.checkAvailability(dateString) + isCheckingAvailability = false + + when (availabilityResult) { + is AvailabilityResult.Busy -> { + showBusyAlert = true + } + is AvailabilityResult.Available -> { + onCreateEventClick(dateTimeString) + showTimePickerDialog = false + } + null -> { + // Network error or other issue - proceed with creation anyway + onCreateEventClick(dateTimeString) + showTimePickerDialog = false + } + } + } + } + }, + enabled = !isCheckingAvailability, + ) { + if (isCheckingAvailability) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Text("Create") + } + } + }, + dismissButton = { + Button( + onClick = { + showTimePickerDialog = false + }, + enabled = !isCheckingAvailability, + ) { + Text("Cancel") + } + }, + timePickerState = timePickerState, + ) + } + + if (showBusyAlert) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { + showBusyAlert = false + }, + title = { + Text("Not Available") + }, + text = { + Text("You are busy on this day. Please select another date.") + }, + confirmButton = { + Button( + onClick = { + showBusyAlert = false + }, + ) { + Text("OK") + } + }, + ) + } } @Composable private fun EventCard( event: MainEvent, onClick: () -> Unit, + isPending: Boolean = false, ) { + // Light orange/peach background for pending events (similar to iOS) + val backgroundColor = if (isPending) { + Color(red = 1.0f, green = 0.97f, blue = 0.92f).copy(alpha = 0.6f) + } else { + MaterialTheme.colorScheme.surface + } + Card( modifier = Modifier .fillMaxWidth() @@ -239,38 +444,108 @@ private fun EventCard( ) { Column( modifier = Modifier + .background(backgroundColor) .padding(16.dp) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = event.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + // Title with pending badge Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Date: ${event.date}", + text = event.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + if (isPending) { + Box( + modifier = Modifier + .background( + Color(red = 1.0f, green = 0.647f, blue = 0.0f), // Orange color + shape = MaterialTheme.shapes.small, + ) + .clip(MaterialTheme.shapes.small) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = "Pending", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + ) + } + } + } + + // Date + Text( + text = event.date, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + + // Time (if present) + event.time?.let { time -> + Text( + text = time, style = MaterialTheme.typography.bodySmall, + color = Color.Gray, ) } - if (!event.time.isNullOrEmpty()) { + + // Participants with person icon + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.Gray, + ) Text( - text = "Time: ${event.time}", + text = "${event.participantCount} participants", style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(start = 4.dp), ) } - Text( - text = "Participants: ${event.participantCount}", - style = MaterialTheme.typography.bodySmall, - ) } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DateTimePickerDialog( + onDismissRequest: () -> Unit, + confirmButton: @Composable () -> Unit, + dismissButton: @Composable () -> Unit, + timePickerState: androidx.compose.material3.TimePickerState, +) { + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text("Select Time") }, + text = { + TimePicker( + state = timePickerState, + layoutType = TimePickerLayoutType.Vertical, + ) + }, + confirmButton = confirmButton, + dismissButton = dismissButton, + ) +} + +enum class FilterMode { + ACTIVE, + PENDING, +} + private fun logScreenOpen(screenName: String) { // TODO: Wire Firebase Analytics here // FirebaseAnalytics.getInstance().logEvent( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04a1c8f..0fad7e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ junit = "4.13.2" kotlin = "2.3.0" material3 = "1.10.0-alpha05" androidx-navigation = "2.8.5" +accompanist = "0.34.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -58,6 +59,7 @@ compose-components-resources = { module = "org.jetbrains.compose.components:comp compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeIcons" } +accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/iosApp/Utils/EventDetail+Identifiable.swift b/iosApp/Utils/EventDetail+Identifiable.swift new file mode 100644 index 0000000..5ad0860 --- /dev/null +++ b/iosApp/Utils/EventDetail+Identifiable.swift @@ -0,0 +1,10 @@ +// +// EventDetail+Identifiable.swift +// iosApp +// +// Created by Данил Забинский on 18.05.2026. +// + +import Shared + +extension EventDetail: @retroactive Identifiable {} diff --git a/iosApp/iosApp/Helper/ToastModifier.swift b/iosApp/iosApp/Helper/ToastModifier.swift new file mode 100644 index 0000000..c1fa1cc --- /dev/null +++ b/iosApp/iosApp/Helper/ToastModifier.swift @@ -0,0 +1,41 @@ +// +// ToastModifier.swift +// iosApp +// +// Created by Данил Забинский on 18.05.2026. +// + +import SwiftUI + +struct ToastModifier: ViewModifier { + @State var isPresented: Bool + let message: String + + func body(content: Content) -> some View { + ZStack { + content + + if isPresented { + VStack { + Text(message) + .font(.subheadline) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.black.opacity(0.8)) + .cornerRadius(8) + .padding() + + Spacer() + } + .transition(.opacity) + } + } + } +} + +extension View { + func toast(isPresented: Binding, message: String) -> some View { + modifier(ToastModifier(isPresented: isPresented.wrappedValue, message: message)) + } +} diff --git a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventDetailSheet.swift b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventDetailSheet.swift new file mode 100644 index 0000000..178806c --- /dev/null +++ b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventDetailSheet.swift @@ -0,0 +1,157 @@ +// +// PendingEventDetailSheet.swift +// iosApp +// +// Created by Данил Забинский on 18.05.2026. +// + +import SwiftUI +import Shared + +struct PendingEventDetailSheet: View { + + let eventDetail: EventDetail + let isLoading: Bool + let error: String? + let onAccept: () -> Void + let onDecline: () -> Void + let onDismiss: () -> Void + + @Environment(\.dismiss) var dismiss + + private func statusColor(_ status: String) -> Color { + switch status.lowercased() { + case "accepted": + return .green + case "declined": + return .red + case "pending": + return .orange + default: + return .gray + } + } + + var body: some View { + NavigationStack { + if let error = error { + VStack { + Text(error) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + } + } else if isLoading { + ProgressView() + } else { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text(eventDetail.title) + .font(.title2) + .fontWeight(.bold) + + if let description = eventDetail.description_ { + Text(description) + .font(.body) + .foregroundColor(.secondary) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Label(eventDetail.date, systemImage: "calendar") + .font(.subheadline) + + if let time = eventDetail.time { + Label(time, systemImage: "clock") + .font(.subheadline) + } + + if let location = eventDetail.location { + Label(location, systemImage: "location.fill") + .font(.subheadline) + } + + HStack { + Text("Status:") + .font(.subheadline) + Spacer() + Text(eventDetail.status) + .font(.subheadline) + .foregroundColor(statusColor(eventDetail.status)) + .fontWeight(.semibold) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Participants") + .font(.headline) + + VStack(spacing: 12) { + ForEach(eventDetail.participants, id: \.userId) { participant in + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(participant.username) + .font(.body) + .foregroundStyle(.primary) + Text(participant.role) + .font(.caption) + .foregroundStyle(.gray) + } + + Spacer() + + Text(participant.responseStatus) + .font(.subheadline) + .foregroundColor(statusColor(participant.responseStatus)) + } + .padding(.vertical, 4) + } + } + } + + Spacer() + .frame(height: 16) + + VStack(spacing: 12) { + Button(action: onAccept) { + Text("Accept Invitation") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(isLoading) + + Button(action: onDecline) { + Text("Decline Invitation") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(isLoading) + } + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + dismiss() + } + } + } + } + } + } +} diff --git a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift new file mode 100644 index 0000000..96eda97 --- /dev/null +++ b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift @@ -0,0 +1,98 @@ +// +// PendingEventListView.swift +// iosApp +// +// Created by Данил Забинский on 18.05.2026. +// + +import SwiftUI +import Shared + +struct PendingEventListView: View { + + @State private var reducer = PendingEventReducer() + @Environment(Router.self) private var router + + var body: some View { + VStack { + if reducer.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = reducer.errorMessage { + VStack { + Text(errorMessage) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(reducer.pendingEvents, id: \.id) { event in + VStack(alignment: .leading, spacing: 8) { + Text(event.title) + .font(.headline) + + HStack(spacing: 12) { + Image(systemName: "calendar") + .foregroundColor(.gray) + Text(event.date) + .font(.subheadline) + .foregroundColor(.gray) + } + + if let time = event.time { + HStack(spacing: 12) { + Image(systemName: "clock") + .foregroundColor(.gray) + Text(time) + .font(.subheadline) + .foregroundColor(.gray) + } + } + + HStack(spacing: 12) { + Image(systemName: "person.2") + .foregroundColor(.gray) + Text("\(event.participants.count) participants") + .font(.subheadline) + .foregroundColor(.gray) + } + } + .padding(.vertical, 4) + .onTapGesture { + reducer.fetchEventDetail(eventId: event.id) + } + } + .refreshable { + reducer.refresh() + } + } + } + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + router.pop() + } label: { + Image(systemName: "chevron.left") + .padding() + } + } + } + .navigationTitle("Pending Invitations") + .sheet(item: Binding( + get: { reducer.selectedEventDetail }, + set: { _ in reducer.closeEventDetail() } + )) { eventDetail in + PendingEventDetailSheet( + eventDetail: eventDetail, + isLoading: reducer.isLoadingDetail, + error: reducer.detailError, + onAccept: { reducer.acceptEvent(eventId: eventDetail.id) }, + onDecline: { reducer.declineEvent(eventId: eventDetail.id) }, + onDismiss: { reducer.closeEventDetail() } + ) + } + .toast(isPresented: $reducer.showToast, message: reducer.toastMessage ?? "") + } +} diff --git a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventReducer.swift b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventReducer.swift new file mode 100644 index 0000000..d5791a5 --- /dev/null +++ b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventReducer.swift @@ -0,0 +1,99 @@ +// +// PendingEventReducer.swift +// iosApp +// +// Created by Данил Забинский on 18.05.2026. +// + +import SwiftUI +import Shared + +@Observable +final class PendingEventReducer { + + var pendingEvents: [Event] = [] + var selectedEventDetail: EventDetail? + var isLoading: Bool = false + var isRefreshing: Bool = false + var detailError: String? + var isLoadingDetail: Bool = false + var toastMessage: String? + var showToast: Bool = false + + private let sharedVM: PendingEventViewModel + private var stateTask: Task? + private var actionTask: Task? + + init() { + self.sharedVM = PendingEventViewModel() + let scope = sharedVM.viewModelScope + + stateTask = Task { + for await state in sharedVM.viewStates.asAsyncStream(scope: scope) { + guard let pendingState = state as? PendingViewState else { continue } + switch pendingState { + case is PendingViewState.Loading: + self.isLoading = true + self.errorMessage = nil + case let error as PendingViewState.Error: + self.errorMessage = error.message + self.isLoading = false + case let content as PendingViewState.Content: + self.pendingEvents = content.events + self.isRefreshing = content.isRefreshing + self.selectedEventDetail = content.selectedEventDetail + self.isLoadingDetail = content.isLoadingDetail + self.detailError = content.detailError + self.isLoading = false + self.errorMessage = nil + default: + self.errorMessage = nil + self.isLoading = false + } + } + } + + actionTask = Task { + for await action in sharedVM.viewActions.asAsyncStream(scope: scope) { + guard let pendingAction = action as? PendingAction else { continue } + switch pendingAction { + case let error as PendingAction.ShowMessage: + self.toastMessage = error.message + self.showToast = true + try? await Task.sleep(nanoseconds: 2_000_000_000) + self.showToast = false + default: + break + } + } + } + } + + var errorMessage: String? + + deinit { + stateTask?.cancel() + actionTask?.cancel() + sharedVM.clear() + } + + func refresh() { + sharedVM.obtainEvent(event: PendingEvent.OnRefresh()) + } + + func fetchEventDetail(eventId: String) { + sharedVM.obtainEvent(event: PendingEvent.OnEventClick(eventId: eventId)) + } + + func acceptEvent(eventId: String) { + sharedVM.obtainEvent(event: PendingEvent.OnAcceptEvent(eventId: eventId)) + } + + func declineEvent(eventId: String) { + sharedVM.obtainEvent(event: PendingEvent.OnDeclineEvent(eventId: eventId)) + } + + func closeEventDetail() { + sharedVM.closeEventDetail() + } +} diff --git a/iosApp/iosApp/Modules/Main/MainEventsReducer.swift b/iosApp/iosApp/Modules/Main/MainEventsReducer.swift index 190cf98..b14060c 100644 --- a/iosApp/iosApp/Modules/Main/MainEventsReducer.swift +++ b/iosApp/iosApp/Modules/Main/MainEventsReducer.swift @@ -8,15 +8,33 @@ import SwiftUI import Shared +enum EventFilter: Int, CaseIterable { + case active = 0 + case pending = 1 + + var title: String { + switch self { + case .active: + return "Active" + case .pending: + return "Pending" + } + } +} + @Observable final class MainEventsReducer { - - var upcomingEvents: [MainEvent] = [] + + var activeEvents: [MainEvent] = [] + var pendingEvents: [MainEvent] = [] var isRefreshing: Bool = false - + var isLoading: Bool = false var errorMessage: String? + var selectedFilter: EventFilter = .active + var isCheckingAvailability: Bool = false + private let sharedVM: MainViewModel private var stateTask: Task? @@ -35,7 +53,8 @@ final class MainEventsReducer { self.errorMessage = error.message self.isLoading = false case let content as MainViewState.Content: - self.upcomingEvents = content.upcomingEvents + self.activeEvents = content.activeEvents + self.pendingEvents = content.pendingEvents self.isRefreshing = content.isRefreshing self.isLoading = false self.errorMessage = nil @@ -53,6 +72,40 @@ final class MainEventsReducer { } func refresh() { - // TODO: ON REFRESH + sharedVM.obtainEvent(event: MainViewAction.OnRefresh()) + } + + func checkAvailability(date: String) async -> Bool { + isCheckingAvailability = true + defer { isCheckingAvailability = false } + do { + let result = try await sharedVM.checkAvailability(date: date) + guard let availability = result else { return true } + + switch availability { + case is AvailabilityResult.Busy: + return false + case is AvailabilityResult.Available: + return true + default: + return true + } + } catch { + self.errorMessage = error.localizedDescription + return false + } + } + + var filteredEvents: [MainEvent] { + switch selectedFilter { + case .active: + return activeEvents + case .pending: + return pendingEvents + } + } + + var pendingCount: Int { + pendingEvents.count } } diff --git a/iosApp/iosApp/Modules/Main/MainView.swift b/iosApp/iosApp/Modules/Main/MainView.swift index 65da060..c2ce52e 100644 --- a/iosApp/iosApp/Modules/Main/MainView.swift +++ b/iosApp/iosApp/Modules/Main/MainView.swift @@ -9,66 +9,239 @@ import SwiftUI import Shared struct MainView: View { - + @State private var reducer = MainEventsReducer() @State private var isCreatingEventInProgress = false @State private var selectedDate = Date() + @State private var showBusyAlert = false + @State private var selectedDateForEvent: String? @Environment(Router.self) private var router - + private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" return formatter }() - + var body: some View { VStack { + // Header with Bell Button and Badge + HStack { + Spacer() + Button { + router.push(screen: .pendingEvents) + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell.fill") + .font(.title2) + + if reducer.pendingCount > 0 { + Text("\(reducer.pendingCount)") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white) + .frame(width: 20, height: 20) + .background(Color.red) + .clipShape(Circle()) + .offset(x: 8, y: -8) + } + } + } + .padding() + } + if reducer.isLoading { ProgressView() + .frame(maxHeight: .infinity, alignment: .center) } else if let errorMessage = reducer.errorMessage { - Text(errorMessage) - .foregroundColor(.red) + VStack(spacing: 12) { + Image(systemName: "exclamationmark.circle.fill") + .font(.largeTitle) + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + .multilineTextAlignment(.center) + Button(action: { + reducer.refresh() + }) { + Text("Retry") + .font(.headline) + } + .buttonStyle(.bordered) + } + .padding() + .frame(maxHeight: .infinity, alignment: .center) + } else if reducer.activeEvents.isEmpty && reducer.pendingEvents.isEmpty { + // Empty state: no events at all + VStack(spacing: 12) { + Image(systemName: "calendar") + .font(.largeTitle) + .foregroundColor(.gray) + Text("You have no events, create it now!") + .foregroundColor(.gray) + } + .frame(maxHeight: .infinity, alignment: .center) } else { - List(reducer.upcomingEvents, id: \.id) { event in - VStack(alignment: .leading) { - Text(event.title) - Text("Date: \(event.date)") - if let time = event.time { - Text("Time: \(time)") + // Events List with sections + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Active Events Section + if !reducer.activeEvents.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Active") + .font(.headline) + .fontWeight(.semibold) + .padding(.horizontal) + + VStack(spacing: 0) { + ForEach(reducer.activeEvents, id: \.id) { event in + EventRowView(event: event, isPending: false) + .onTapGesture { + router.push(screen: .eventDetail(id: event.id)) + } + .padding(.horizontal) + } + } + } + } + + // Pending Events Section + if !reducer.pendingEvents.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Pending") + .font(.headline) + .fontWeight(.semibold) + .padding(.horizontal) + + VStack(spacing: 0) { + ForEach(reducer.pendingEvents, id: \.id) { event in + EventRowView(event: event, isPending: true) + .onTapGesture { + router.push(screen: .eventDetail(id: event.id)) + } + .padding(.horizontal) + } + } + } } - Text("Participants: \(event.participantCount)") - } - .onTapGesture { - router.push(screen: .eventDetail(id: event.id)) } - } - .refreshable { - reducer.refresh() + .padding(.vertical) } } - - Button { + + Button(action: { isCreatingEventInProgress = true - } label: { + }) { Text("Create event") + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding() + .refreshable { + reducer.refresh() } } .navigationTitle("Home") .sheet(isPresented: $isCreatingEventInProgress) { - DatePicker( - "Select event date", - selection: $selectedDate, - in: Date()..., - displayedComponents: [.date, .hourAndMinute] - ) - .datePickerStyle(.graphical) - .padding() + VStack(spacing: 16) { + DatePicker( + "Select event date", + selection: $selectedDate, + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + .datePickerStyle(.graphical) + .padding() + + Button("Create") { + let dateString = dateFormatter.string(from: selectedDate) + Task { + let isAvailable = await reducer.checkAvailability(date: dateString) + if isAvailable { + router.push(screen: .createEvent(date: dateString)) + isCreatingEventInProgress = false + } else { + selectedDateForEvent = dateString + showBusyAlert = true + } + } + } + .disabled(reducer.isCheckingAvailability) + } .presentationDetents([.medium]) - - Button("Create") { - router.push(screen: .createEvent(date: dateFormatter.string(from: selectedDate))) - isCreatingEventInProgress = false + } + .alert("You are busy on this day", isPresented: $showBusyAlert) { + Button("OK") { + showBusyAlert = false + } + } + } +} + +// MARK: - Event Row Component +struct EventRowView: View { + let event: MainEvent + let isPending: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(event.title) + .font(.headline) + .fontWeight(.semibold) + + if isPending { + Text("PENDING") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.orange) + .cornerRadius(4) + } + + Spacer() + } + + HStack(spacing: 16) { + HStack(spacing: 4) { + Image(systemName: "calendar") + .foregroundColor(.gray) + Text(event.date) + .font(.caption) + .foregroundColor(.gray) + } + + if let time = event.time { + HStack(spacing: 4) { + Image(systemName: "clock") + .foregroundColor(.gray) + Text(time) + .font(.caption) + .foregroundColor(.gray) + } + } + + Spacer() + } + + HStack(spacing: 4) { + Image(systemName: "person.2") + .foregroundColor(.gray) + Text("\(event.participantCount) participants") + .font(.caption) + .foregroundColor(.gray) } } + .padding(.vertical, 4) + .listRowBackground( + isPending ? + Color(red: 1.0, green: 0.97, blue: 0.92).opacity(0.6) : + Color.clear + ) } } diff --git a/iosApp/iosApp/Modules/Root/RootView.swift b/iosApp/iosApp/Modules/Root/RootView.swift index a835245..a0ba1fc 100644 --- a/iosApp/iosApp/Modules/Root/RootView.swift +++ b/iosApp/iosApp/Modules/Root/RootView.swift @@ -22,6 +22,8 @@ struct RootView: View { CreateEventView(date: date) case let .eventDetail(id): EventDetailView(eventId: id) + case .pendingEvents: + PendingEventListView() default: EmptyView() } diff --git a/iosApp/iosApp/Navigation/AppRouter.swift b/iosApp/iosApp/Navigation/AppRouter.swift index 983279f..3efeabd 100644 --- a/iosApp/iosApp/Navigation/AppRouter.swift +++ b/iosApp/iosApp/Navigation/AppRouter.swift @@ -9,9 +9,10 @@ import SwiftUI import Shared enum AppRouter: Hashable { - + case login case editProfile(profile: Profile) case createEvent(date: String) case eventDetail(id: String) + case pendingEvents } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/remote/EventsApi.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/remote/EventsApi.kt index 2cd3b34..744d8a4 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/remote/EventsApi.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/remote/EventsApi.kt @@ -4,6 +4,7 @@ import friends.mobile.feature.events.data.remote.dto.CheckAvailabilityResponseDt import friends.mobile.feature.events.data.remote.dto.CreateEventRequestDto import friends.mobile.feature.events.data.remote.dto.CreateEventResponseDto import friends.mobile.feature.events.data.remote.dto.EventResponseDto +import friends.mobile.feature.events.data.remote.dto.UserAvailabilityResponseDto import friends.mobile.feature.main.data.remote.dto.EventListItemDto import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -26,6 +27,11 @@ internal class EventsApi( parameter("date", date) }.body() + suspend fun checkUserAvailability(date: String): UserAvailabilityResponseDto = + client.get("/events/check-user-availability") { + parameter("date", date) + }.body() + suspend fun getEvents(scope: String = "upcoming"): List = client.get("/events") { parameter("scope", scope) @@ -34,6 +40,9 @@ internal class EventsApi( suspend fun getEvent(eventId: String): EventResponseDto = client.get("/events/$eventId").body() + suspend fun getActiveEvents(): List = + client.get("/events/active").body() + suspend fun getPendingEvents(): List = client.get("/events/pending").body() diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/remote/dto/UserAvailabilityResponseDto.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/remote/dto/UserAvailabilityResponseDto.kt new file mode 100644 index 0000000..f956b8e --- /dev/null +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/remote/dto/UserAvailabilityResponseDto.kt @@ -0,0 +1,9 @@ +package friends.mobile.feature.events.data.remote.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserAvailabilityResponseDto( + @SerialName("is_available") val isAvailable: Boolean, +) diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetPendingEventsUseCaseImpl.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetPendingEventsUseCaseImpl.kt index e660ca7..14c9bde 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetPendingEventsUseCaseImpl.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetPendingEventsUseCaseImpl.kt @@ -2,6 +2,7 @@ package friends.mobile.feature.events.data.usecase import friends.mobile.core.domain.model.ResultWrapper import friends.mobile.feature.events.domain.model.Event +import friends.mobile.feature.events.domain.model.ParticipationStatus import friends.mobile.feature.events.domain.repository.EventsRepository import friends.mobile.feature.events.domain.usecase.GetPendingEventsUseCase @@ -9,6 +10,16 @@ internal class GetPendingEventsUseCaseImpl( private val repository: EventsRepository, ) : GetPendingEventsUseCase { - override suspend fun invoke(): ResultWrapper> = - repository.getPendingEvents() + override suspend fun invoke(): ResultWrapper> { + val result = repository.getPendingEvents() + if (result is ResultWrapper.Error) return result + + val events = (result as ResultWrapper.Success).data + val filtered = events.filter { event -> + event.participants.any { participant -> + participant.status == ParticipationStatus.INVITED + } + } + return ResultWrapper.Success(filtered) + } } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewModel.kt index 7db6341..200bb60 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewModel.kt @@ -4,6 +4,7 @@ import friends.mobile.core.domain.model.ResultWrapper import friends.mobile.core.domain.model.getErrorMessage import friends.mobile.core.domain.model.mapApiErrorToUserFriendly import friends.mobile.core.viewmodel.BaseViewModel +import friends.mobile.feature.auth.domain.usecase.GetStoredSessionUseCase import friends.mobile.feature.events.domain.usecase.CheckFriendsAvailabilityUseCase import friends.mobile.feature.events.domain.usecase.CreateEventUseCase import kotlinx.coroutines.launch @@ -19,6 +20,7 @@ class CreateEventViewModel( private val checkFriendsAvailabilityUseCase: CheckFriendsAvailabilityUseCase by inject() private val createEventUseCase: CreateEventUseCase by inject() + private val getStoredSessionUseCase: GetStoredSessionUseCase by inject() init { viewModelScope.launch { @@ -41,10 +43,21 @@ class CreateEventViewModel( viewModelScope.launch { when (val result = checkFriendsAvailabilityUseCase(selectedDate)) { is ResultWrapper.Success -> { + // Check if the owner (current user) is available on this date + val currentSession = getStoredSessionUseCase() + val ownerId = currentSession?.user?.id + + // The API returns only available friends, so owner is available if: + // 1. Owner ID exists in the available friends list, OR + // 2. If owner is not in the list, it means they're busy + val availableFriendsIds = result.data.map { it.id }.toSet() + val isOwnerAvailable = ownerId != null && availableFriendsIds.contains(ownerId) + viewState = CreateEventViewState.Content( selectedDate = selectedDate, availableFriends = result.data, isLoadingFriends = false, + isOwnerAvailable = isOwnerAvailable, ) } is ResultWrapper.Error -> { @@ -85,6 +98,11 @@ class CreateEventViewModel( return } + if (!currentState.isOwnerAvailable) { + viewState = CreateEventViewState.Error(message = "You are not available on this date. Please select a different date for the event.") + return + } + val invitedIds = currentState.selectedFriendIds.toList() viewState = currentState.copy(isCreatingEvent = true) @@ -121,6 +139,7 @@ class CreateEventViewModel( } private fun computeIsCreateButtonEnabled(title: String, selectedFriendIds: Set): Boolean { - return title.isNotBlank() && selectedFriendIds.isNotEmpty() + val currentState = viewState as? CreateEventViewState.Content ?: return false + return title.isNotBlank() && selectedFriendIds.isNotEmpty() && currentState.isOwnerAvailable } } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewState.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewState.kt index 6596923..5dfa22a 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewState.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewState.kt @@ -17,6 +17,7 @@ sealed class CreateEventViewState { val selectedFriendIds: Set = emptySet(), val isLoadingFriends: Boolean = false, val friendsError: String? = null, + val isOwnerAvailable: Boolean = true, val isCreatingEvent: Boolean = false, val isCreateButtonEnabled: Boolean = false, ) : CreateEventViewState() diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/mapper/MainEventMapper.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/mapper/MainEventMapper.kt index 9c9bcd6..8a67982 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/mapper/MainEventMapper.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/mapper/MainEventMapper.kt @@ -1,5 +1,6 @@ package friends.mobile.feature.main.data.mapper +import friends.mobile.feature.events.data.remote.dto.EventResponseDto import friends.mobile.feature.main.data.remote.dto.EventListItemDto import friends.mobile.feature.main.domain.model.MainEvent @@ -19,4 +20,19 @@ internal class MainEventMapper { fun toDomain(dtos: List): List { return dtos.map { toDomain(it) } } + + fun toDomainFromEventResponse(dto: EventResponseDto): MainEvent { + return MainEvent( + id = dto.id, + title = dto.title, + date = dto.date, + time = dto.time ?: "12:00", + creatorId = dto.creatorId, + participantCount = dto.participants.size, + ) + } + + fun toDomainFromEventResponse(dtos: List): List { + return dtos.map { toDomainFromEventResponse(it) } + } } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/repository/MainRepositoryImpl.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/repository/MainRepositoryImpl.kt index a797b33..4cef6e1 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/repository/MainRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/repository/MainRepositoryImpl.kt @@ -3,7 +3,9 @@ package friends.mobile.feature.main.data.repository import friends.mobile.core.domain.model.ResultWrapper import friends.mobile.core.network.safeApiCall import friends.mobile.feature.events.data.remote.EventsApi +import friends.mobile.feature.events.data.remote.dto.EventResponseDto import friends.mobile.feature.main.data.mapper.MainEventMapper +import friends.mobile.feature.main.domain.model.AvailabilityResult import friends.mobile.feature.main.domain.model.MainEvent import friends.mobile.feature.main.domain.repository.MainRepository @@ -14,7 +16,33 @@ internal class MainRepositoryImpl( override suspend fun getAcceptedEvents(): ResultWrapper> = safeApiCall { - val response = api.getEvents(scope = "upcoming") - eventMapper.toDomain(response) + val activeEvents = api.getActiveEvents() + eventMapper.toDomainFromEventResponse(activeEvents) + } + + override suspend fun getPendingEvents(): ResultWrapper> = + safeApiCall { + val pendingEvents = api.getPendingEvents() + eventMapper.toDomainFromEventResponse(pendingEvents) + } + + override suspend fun getActiveAndPendingEvents(): ResultWrapper, List>> = + safeApiCall { + val activeEvents = api.getActiveEvents() + val pendingEvents = api.getPendingEvents() + Pair( + eventMapper.toDomainFromEventResponse(activeEvents), + eventMapper.toDomainFromEventResponse(pendingEvents) + ) + } + + override suspend fun checkUserAvailability(date: String): ResultWrapper = + safeApiCall { + val response = api.checkUserAvailability(date) + if (response.isAvailable) { + AvailabilityResult.Available + } else { + AvailabilityResult.Busy + } } } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/model/AvailabilityResult.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/model/AvailabilityResult.kt new file mode 100644 index 0000000..baa1ca3 --- /dev/null +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/model/AvailabilityResult.kt @@ -0,0 +1,6 @@ +package friends.mobile.feature.main.domain.model + +sealed class AvailabilityResult { + data object Available : AvailabilityResult() + data object Busy : AvailabilityResult() +} diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/repository/MainRepository.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/repository/MainRepository.kt index 1a3cdce..404c019 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/repository/MainRepository.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/repository/MainRepository.kt @@ -1,8 +1,15 @@ package friends.mobile.feature.main.domain.repository import friends.mobile.core.domain.model.ResultWrapper +import friends.mobile.feature.main.domain.model.AvailabilityResult import friends.mobile.feature.main.domain.model.MainEvent interface MainRepository { suspend fun getAcceptedEvents(): ResultWrapper> + + suspend fun getPendingEvents(): ResultWrapper> + + suspend fun getActiveAndPendingEvents(): ResultWrapper, List>> + + suspend fun checkUserAvailability(date: String): ResultWrapper } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainEvent.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainEvent.kt index a208f9e..132df91 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainEvent.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainEvent.kt @@ -1,5 +1,5 @@ package friends.mobile.feature.main.presentation -sealed class MainEvent { - data object OnRefresh : MainEvent() +sealed class MainViewAction { + data object OnRefresh : MainViewAction() } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt index 81e3731..a4c2d60 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt @@ -4,12 +4,13 @@ import friends.mobile.core.domain.model.ResultWrapper import friends.mobile.core.domain.model.getErrorMessage import friends.mobile.core.domain.model.mapApiErrorToUserFriendly import friends.mobile.core.viewmodel.BaseViewModel +import friends.mobile.feature.main.domain.model.AvailabilityResult import friends.mobile.feature.main.domain.repository.MainRepository import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject -class MainViewModel : BaseViewModel( +class MainViewModel : BaseViewModel( initState = MainViewState.Loading, ), KoinComponent { @@ -22,18 +23,20 @@ class MainViewModel : BaseViewModel( } } - override fun obtainEvent(event: MainEvent) { + override fun obtainEvent(event: MainViewAction) { when (event) { - is MainEvent.OnRefresh -> onRefresh() + is MainViewAction.OnRefresh -> onRefresh() } } private fun loadEvents() { viewModelScope.launch { - when (val result = mainRepository.getAcceptedEvents()) { + when (val result = mainRepository.getActiveAndPendingEvents()) { is ResultWrapper.Success -> { + val (activeEvents, pendingEvents) = result.data viewState = MainViewState.Content( - upcomingEvents = result.data, + activeEvents = activeEvents, + pendingEvents = pendingEvents, isRefreshing = false, ) } @@ -54,4 +57,11 @@ class MainViewModel : BaseViewModel( } loadEvents() } + + suspend fun checkAvailability(date: String): AvailabilityResult? { + return when (val result = mainRepository.checkUserAvailability(date)) { + is ResultWrapper.Success -> result.data + is ResultWrapper.Error -> null + } + } } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewState.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewState.kt index 6806751..b31a710 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewState.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewState.kt @@ -8,7 +8,8 @@ sealed class MainViewState { data class Error(val message: String) : MainViewState() data class Content( - val upcomingEvents: List = emptyList(), + val activeEvents: List = emptyList(), + val pendingEvents: List = emptyList(), val isRefreshing: Boolean = false, ) : MainViewState() }