diff --git a/app/src/main/java/com/example/talkeys_new/navigation/AppNavigation.kt b/app/src/main/java/com/example/talkeys_new/navigation/AppNavigation.kt index abef9c2..78e374f 100644 --- a/app/src/main/java/com/example/talkeys_new/navigation/AppNavigation.kt +++ b/app/src/main/java/com/example/talkeys_new/navigation/AppNavigation.kt @@ -27,6 +27,7 @@ import com.example.talkeys_new.screens.events.createEvent.CreateEvent4Screen import com.example.talkeys_new.screens.events.createEvent.CreateEvent5Screen import com.example.talkeys_new.screens.events.createEvent.CreateEvent6Screen import com.example.talkeys_new.screens.events.RegistrationSuccessScreen +import com.example.talkeys_new.screens.events.sharedEventCreationViewModel import com.example.talkeysapk.screensUI.home.AboutUsScreen import com.example.talkeysapk.screensUI.home.ContactUsScreen import com.example.talkeysapk.screensUI.home.TermsAndConditionsScreen @@ -35,8 +36,9 @@ import com.example.talkeysapk.screensUI.home.privacyPolicy @Composable fun AppNavigation(modifier: Modifier) { val navController = rememberNavController() + val eventCreationViewModel = sharedEventCreationViewModel() - NavHost(navController = navController, startDestination = "landingpage") { + NavHost(navController = navController, startDestination = "home") { //composable("splash") { SplashScreen(navController) } composable("landingpage") { LandingPage(navController) } composable("signup") { SignUpScreen(navController) } @@ -61,12 +63,12 @@ fun AppNavigation(modifier: Modifier) { composable("screen_not_found"){ScreenNotFound(navController)} // Create Event Flow - composable("create_event_1") { CreateEvent1Screen(navController) } - composable("create_event_2") { CreateEvent2Screen(navController) } - composable("create_event_3") { CreateEvent3Screen(navController) } - composable("create_event_4") { CreateEvent4Screen(navController) } - composable("create_event_5") { CreateEvent5Screen(navController) } - composable("create_event_6") { CreateEvent6Screen(navController) } + composable("create_event_1") { CreateEvent1Screen(navController, eventCreationViewModel) } + composable("create_event_2") { CreateEvent2Screen(navController, eventCreationViewModel) } + composable("create_event_3") { CreateEvent3Screen(navController, eventCreationViewModel) } + composable("create_event_4") { CreateEvent4Screen(navController, eventCreationViewModel) } + composable("create_event_5") { CreateEvent5Screen(navController, eventCreationViewModel) } + composable("create_event_6") { CreateEvent6Screen(navController, eventCreationViewModel) } composable("registration_success") { RegistrationSuccessScreen(navController) } // Event Detail Screen with eventId parameter @@ -130,25 +132,7 @@ fun AppNavigation(modifier: Modifier) { val merchantOrderId = backStackEntry.arguments?.getString("merchantOrderId") ?: "" val passId = backStackEntry.arguments?.getString("passId") ?: "" - // Decode the URL structure but preserve the token encoding - // Split at "token=" to handle the token separately - val paymentUrl = if (encodedPaymentUrl.contains("token%3D")) { - val parts = encodedPaymentUrl.split("token%3D", limit = 2) - if (parts.size == 2) { - // Decode the URL part before token - val urlPart = parts[0] - .replace("%3A", ":") - .replace("%2F", "/") - .replace("%3F", "?") - // Keep the token part as-is (already properly encoded) - urlPart + "token=" + parts[1] - } else { - encodedPaymentUrl - } - } else { - // Fallback: decode everything - android.net.Uri.decode(encodedPaymentUrl) - } + val paymentUrl = decodePaymentUrlArgument(encodedPaymentUrl) com.example.talkeys_new.screens.payment.WebViewPaymentScreen( paymentUrl = paymentUrl, @@ -178,3 +162,11 @@ fun AppNavigation(modifier: Modifier) { } } + +private fun decodePaymentUrlArgument(paymentUrlArgument: String): String { + if (paymentUrlArgument.startsWith("http://") || paymentUrlArgument.startsWith("https://")) { + return paymentUrlArgument + } + + return android.net.Uri.decode(paymentUrlArgument) +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/AndroidEventPlatformRequestHandler.kt b/app/src/main/java/com/example/talkeys_new/screens/events/AndroidEventPlatformRequestHandler.kt new file mode 100644 index 0000000..7c58f27 --- /dev/null +++ b/app/src/main/java/com/example/talkeys_new/screens/events/AndroidEventPlatformRequestHandler.kt @@ -0,0 +1,36 @@ +package com.example.talkeys_new.screens.events + +import android.content.Context +import android.content.Intent +import androidx.navigation.NavController +import com.talkeys.shared.presentation.events.EventPlatformRequest +import com.talkeys.shared.presentation.events.EventsRoute + +fun handleEventPlatformRequest( + context: Context, + navController: NavController, + request: EventPlatformRequest +) { + when (request) { + is EventPlatformRequest.ShareEvent -> { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, request.subject) + putExtra(Intent.EXTRA_TEXT, request.text) + } + context.startActivity(Intent.createChooser(sendIntent, "Share Event")) + } + + is EventPlatformRequest.NavigateToEventDetail -> { + navController.navigate("eventDetail/${request.eventId}") + } + + is EventPlatformRequest.NavigateToRoute -> { + val route = when (request.route) { + EventsRoute.EventList -> "events" + EventsRoute.EventCreation -> "create_event_1" + } + navController.navigate(route) + } + } +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/EventMediatedViewModel.kt b/app/src/main/java/com/example/talkeys_new/screens/events/EventMediatedViewModel.kt deleted file mode 100644 index 1729684..0000000 --- a/app/src/main/java/com/example/talkeys_new/screens/events/EventMediatedViewModel.kt +++ /dev/null @@ -1,320 +0,0 @@ -package com.example.talkeys_new.screens.events - -import android.content.Context -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.talkeys_new.dataModels.EventResponse -import com.example.talkeys_new.screens.events.mediator.* -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -/** - * Enhanced EventViewModel that uses the EventMediator pattern for better - * separation of concerns and centralized event management - */ -class EventMediatedViewModel( - private val context: Context -) : ViewModel(), EventListener { - - companion object { - private const val TAG = "EventMediatedViewModel" - private const val LISTENER_KEY = "EventMediatedViewModel" - } - - // Get mediator instance - private val mediator = EventMediatorProvider.getMediator(context) - - // Expose state flows from mediator - val eventList: StateFlow> = mediator.eventList - val selectedEvent: StateFlow = mediator.selectedEvent - val isLoading: StateFlow = mediator.isLoading - val errorMessage: StateFlow = mediator.errorMessage - - // Additional properties for filtered events if using EventMediatorImpl - val filteredEvents: StateFlow> - get() = (mediator as? EventMediatorImpl)?.filteredEvents - ?: mediator.eventList - - val showLiveEvents: StateFlow - get() = (mediator as? EventMediatorImpl)?.showLiveEvents - ?: kotlinx.coroutines.flow.MutableStateFlow(true) - - val searchQuery: StateFlow - get() = (mediator as? EventMediatorImpl)?.searchQuery - ?: kotlinx.coroutines.flow.MutableStateFlow("") - - val likedEvents: StateFlow> - get() = (mediator as? EventMediatorImpl)?.likedEvents - ?: kotlinx.coroutines.flow.MutableStateFlow(emptySet()) - - init { - // Register as listener - EventMediatorProvider.addListener(LISTENER_KEY, this) - Log.d(TAG, "EventMediatedViewModel initialized and registered as listener") - } - - // ===== Event Data Operations ===== - - /** - * Fetch all events - * @param forceRefresh If true, bypasses cache and fetches fresh data - */ - fun fetchAllEvents(forceRefresh: Boolean = false) { - viewModelScope.launch { - mediator.fetchAllEvents(forceRefresh) - } - } - - /** - * Fetch a specific event by ID - * @param eventId The ID of the event to fetch - * @param forceRefresh If true, bypasses cache and fetches fresh data - */ - fun fetchEventById(eventId: String, forceRefresh: Boolean = false) { - viewModelScope.launch { - mediator.fetchEventById(eventId, forceRefresh) - } - } - - /** - * Refresh all events - */ - fun refreshEvents() { - viewModelScope.launch { - mediator.refreshEvents() - } - } - - // ===== Event Actions ===== - - /** - * Like an event - * @param eventId The ID of the event to like - */ - fun likeEvent(eventId: String) { - viewModelScope.launch { - val success = mediator.likeEvent(eventId) - Log.d(TAG, "Like event $eventId: $success") - } - } - - /** - * Unlike an event - * @param eventId The ID of the event to unlike - */ - fun unlikeEvent(eventId: String) { - viewModelScope.launch { - val success = mediator.unlikeEvent(eventId) - Log.d(TAG, "Unlike event $eventId: $success") - } - } - - /** - * Register for an event - * @param eventId The ID of the event to register for - */ - fun registerForEvent(eventId: String) { - viewModelScope.launch { - val success = mediator.registerForEvent(eventId) - Log.d(TAG, "Register for event $eventId: $success") - } - } - - /** - * Share an event - * @param event The event to share - */ - fun shareEvent(event: EventResponse) { - viewModelScope.launch { - val success = mediator.shareEvent(event) - Log.d(TAG, "Share event ${event.name}: $success") - } - } - - // ===== Filter and Search Operations ===== - - /** - * Toggle between live and past events filter - */ - fun toggleEventFilter() { - mediator.toggleEventFilter() - } - - /** - * Apply search filter - * @param query Search query string - */ - fun applySearchFilter(query: String) { - mediator.applySearchFilter(query) - } - - /** - * Apply category filter - * @param category Category to filter by - */ - fun applyCategoryFilter(category: String) { - mediator.applyCategoryFilter(category) - } - - /** - * Clear all filters - */ - fun clearAllFilters() { - mediator.clearAllFilters() - } - - // ===== Navigation ===== - - /** - * Navigate to event detail - * @param eventId The ID of the event - */ - fun navigateToEventDetail(eventId: String) { - mediator.navigateToEventDetail(eventId) - } - - /** - * Navigate to event creation - */ - fun navigateToEventCreation() { - mediator.navigateToEventCreation() - } - - /** - * Navigate to event list - */ - fun navigateToEventList() { - mediator.navigateToEventList() - } - - // ===== Event Creation Flow ===== - - /** - * Start event creation flow - */ - fun startEventCreation() { - mediator.startEventCreation() - } - - /** - * Proceed to next step in event creation - * @param stepData Data from current step - */ - fun proceedToNextStep(stepData: Map) { - mediator.proceedToNextStep(stepData) - } - - /** - * Go to previous step in event creation - */ - fun goToPreviousStep() { - mediator.goToPreviousStep() - } - - /** - * Save event draft - * @param stepData Current step data - */ - fun saveEventDraft(stepData: Map) { - mediator.saveEventDraft(stepData) - } - - /** - * Submit event for creation - * @param eventData Complete event data - */ - fun submitEvent(eventData: Map) { - val success = mediator.submitEvent(eventData) - Log.d(TAG, "Submit event: $success") - } - - /** - * Cancel event creation - */ - fun cancelEventCreation() { - mediator.cancelEventCreation() - } - - // ===== Error Handling ===== - - /** - * Clear all errors - */ - fun clearErrors() { - mediator.clearErrors() - } - - // ===== Utility Methods ===== - - /** - * Check if an event is liked - * @param eventId The ID of the event - * @return True if the event is liked - */ - fun isEventLiked(eventId: String): Boolean { - return likedEvents.value.contains(eventId) - } - - /** - * Get current filter description - */ - fun getCurrentFilterDescription(): String { - return if (showLiveEvents.value) "Live Events" else "Past Events" - } - - /** - * Get current event count - */ - fun getCurrentEventCount(): Int { - return filteredEvents.value.size - } - - /** - * Check if there are any events available - */ - fun hasEvents(): Boolean { - return eventList.value.isNotEmpty() - } - - // ===== EventListener Implementation ===== - - override fun onEventUpdated(event: EventResponse) { - Log.d(TAG, "Event updated: ${event.name}") - // Additional handling if needed - } - - override fun onEventDeleted(eventId: String) { - Log.d(TAG, "Event deleted: $eventId") - // Additional handling if needed - } - - override fun onEventLiked(eventId: String, isLiked: Boolean) { - Log.d(TAG, "Event $eventId liked: $isLiked") - // Additional handling if needed - } - - override fun onEventRegistered(eventId: String, isRegistered: Boolean) { - Log.d(TAG, "Event $eventId registered: $isRegistered") - // Additional handling if needed - } - - override fun onEventsRefreshed(events: List) { - Log.d(TAG, "Events refreshed: ${events.size} events") - // Additional handling if needed - } - - override fun onError(operation: EventOperation, error: Throwable) { - Log.e(TAG, "Error in operation $operation", error) - // Additional handling if needed - } - - // ===== ViewModel Lifecycle ===== - - override fun onCleared() { - super.onCleared() - // Unregister listener - EventMediatorProvider.removeListener(LISTENER_KEY) - Log.d(TAG, "EventMediatedViewModel cleared and listener unregistered") - } -} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/SharedEventsViewModelProvider.kt b/app/src/main/java/com/example/talkeys_new/screens/events/SharedEventsViewModelProvider.kt index a912872..74c7d79 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/events/SharedEventsViewModelProvider.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/events/SharedEventsViewModelProvider.kt @@ -3,6 +3,10 @@ package com.example.talkeys_new.screens.events import androidx.compose.runtime.Composable import androidx.lifecycle.viewmodel.compose.viewModel import com.talkeys.shared.data.events.EventsRepository +import com.talkeys.shared.presentation.events.EventCoordinator +import com.talkeys.shared.presentation.events.EventCoordinatorFactory +import com.talkeys.shared.presentation.events.EventCreationViewModel +import com.talkeys.shared.presentation.events.EventCreationViewModelFactory import com.talkeys.shared.presentation.events.EventDetailViewModel import com.talkeys.shared.presentation.events.EventDetailViewModelFactory import com.talkeys.shared.presentation.events.EventsListViewModel @@ -37,3 +41,15 @@ fun sharedEventDetailViewModel( ): EventDetailViewModel = viewModel( factory = EventDetailViewModelFactory(repository) ) + +@Composable +fun sharedEventCoordinator( + repository: EventsRepository = koinInject() +): EventCoordinator = viewModel( + factory = EventCoordinatorFactory(repository) +) + +@Composable +fun sharedEventCreationViewModel(): EventCreationViewModel = viewModel( + factory = EventCreationViewModelFactory() +) diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/AndroidSharedImageReader.kt b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/AndroidSharedImageReader.kt new file mode 100644 index 0000000..3b85f70 --- /dev/null +++ b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/AndroidSharedImageReader.kt @@ -0,0 +1,16 @@ +package com.example.talkeys_new.screens.events.createEvent + +import android.content.Context +import android.net.Uri +import com.talkeys.shared.presentation.events.SharedImage + +fun Uri?.toSharedImage(context: Context, fileName: String): SharedImage? { + if (this == null) return null + val mimeType = context.contentResolver.getType(this) ?: "application/octet-stream" + val bytes = context.contentResolver.openInputStream(this)?.use { it.readBytes() } ?: return null + return SharedImage( + bytes = bytes, + mimeType = mimeType, + fileName = fileName.takeUnless { it.isBlank() || it == "No file selected" } ?: "selected-file" + ) +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent1Screen.kt b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent1Screen.kt index 1460ae1..4a62bed 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent1Screen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent1Screen.kt @@ -25,11 +25,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.example.talkeys_new.screens.common.HomeTopBar +import com.talkeys.shared.presentation.events.EventCreationStep +import com.talkeys.shared.presentation.events.EventCreationViewModel +import com.talkeys.shared.presentation.events.OrganizerInfoDraft import java.util.regex.Pattern @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateEvent1Screen(navController: NavController) { +fun CreateEvent1Screen( + navController: NavController, + eventCreationViewModel: EventCreationViewModel +) { + LaunchedEffect(eventCreationViewModel) { + eventCreationViewModel.moveToStep(EventCreationStep.OrganizerInfo) + } + // State variables for form fields var organizerName by remember { mutableStateOf("") } var emailAddress by remember { mutableStateOf("") } @@ -604,8 +614,28 @@ fun CreateEvent1Screen(navController: NavController) { // Next Button Button( onClick = { - if (validateFields()) { + eventCreationViewModel.updateOrganizerInfo( + OrganizerInfoDraft( + organizerName = organizerName, + emailAddress = emailAddress, + contactNumber = contactNumber, + organizationName = organizationName, + cityState = cityState, + socialMediaLinks = socialMediaLinks, + organizerDocument = selectedFileUri.toSharedImage(context, fileName) + ) + ) + if (eventCreationViewModel.goNext()) { navController.navigate("create_event_2") + } else { + val errors = eventCreationViewModel.uiState.value.validationErrors + organizerNameError = errors["organizerName"].orEmpty() + emailError = errors["emailAddress"].orEmpty() + contactNumberError = errors["contactNumber"].orEmpty() + organizationNameError = errors["organizationName"].orEmpty() + cityStateError = errors["cityState"].orEmpty() + socialMediaError = errors["socialMediaLinks"].orEmpty() + fileError = errors["organizerDocument"].orEmpty() } }, enabled = true, // Always enabled, validation happens on click @@ -633,4 +663,4 @@ fun CreateEvent1Screen(navController: NavController) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent2Screen.kt b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent2Screen.kt index cf6ec78..3190db6 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent2Screen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent2Screen.kt @@ -28,11 +28,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.example.talkeys_new.screens.common.HomeTopBar +import com.talkeys.shared.presentation.events.EventCreationStep +import com.talkeys.shared.presentation.events.EventCreationViewModel +import com.talkeys.shared.presentation.events.EventDetailsDraft import java.util.regex.Pattern @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateEvent2Screen(navController: NavController) { +fun CreateEvent2Screen( + navController: NavController, + eventCreationViewModel: EventCreationViewModel +) { + LaunchedEffect(eventCreationViewModel) { + eventCreationViewModel.moveToStep(EventCreationStep.Details) + } + // State variables for form fields var eventName by remember { mutableStateOf("") } var eventType by remember { mutableStateOf("") } @@ -518,6 +528,7 @@ fun CreateEvent2Screen(navController: NavController) { // Previous Button OutlinedButton( onClick = { + eventCreationViewModel.goPrevious() navController.navigate("create_event_1") }, colors = ButtonDefaults.outlinedButtonColors( @@ -552,8 +563,24 @@ fun CreateEvent2Screen(navController: NavController) { // Next Button Button( onClick = { - if (validateFields()) { + eventCreationViewModel.updateDetails( + EventDetailsDraft( + eventName = eventName, + eventType = eventType, + eventCategory = eventCategory, + eventDescription = eventDescription, + eventBanner = selectedFileUri.toSharedImage(context, fileName) + ) + ) + if (eventCreationViewModel.goNext()) { navController.navigate("create_event_3") + } else { + val errors = eventCreationViewModel.uiState.value.validationErrors + eventNameError = errors["eventName"].orEmpty() + eventTypeError = errors["eventType"].orEmpty() + eventCategoryError = errors["eventCategory"].orEmpty() + eventDescriptionError = errors["eventDescription"].orEmpty() + fileError = errors["eventBanner"].orEmpty() } }, enabled = true, // Always enabled, validation happens on click @@ -582,4 +609,4 @@ fun CreateEvent2Screen(navController: NavController) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent3Screen.kt b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent3Screen.kt index dbebccb..583b4b3 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent3Screen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent3Screen.kt @@ -30,12 +30,22 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.example.talkeys_new.R import com.example.talkeys_new.screens.common.HomeTopBar +import com.talkeys.shared.presentation.events.EventCreationStep +import com.talkeys.shared.presentation.events.EventCreationViewModel +import com.talkeys.shared.presentation.events.EventScheduleDraft import java.text.SimpleDateFormat import java.util.* @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateEvent3Screen(navController: NavController) { +fun CreateEvent3Screen( + navController: NavController, + eventCreationViewModel: EventCreationViewModel +) { + LaunchedEffect(eventCreationViewModel) { + eventCreationViewModel.moveToStep(EventCreationStep.Schedule) + } + // State variables for form fields var eventDates by remember { mutableStateOf("") } var startTime by remember { mutableStateOf("") } @@ -680,6 +690,7 @@ fun CreateEvent3Screen(navController: NavController) { // Previous Button OutlinedButton( onClick = { + eventCreationViewModel.goPrevious() navController.navigate("create_event_2") }, colors = ButtonDefaults.outlinedButtonColors( @@ -715,8 +726,26 @@ fun CreateEvent3Screen(navController: NavController) { // Next Button Button( onClick = { - if (validateForm() && areAllFieldsFilled) { + eventCreationViewModel.updateSchedule( + EventScheduleDraft( + eventDates = eventDates, + startTime = startTime, + endTime = endTime, + registrationDeadline = registrationDeadline, + maxAttendees = maxAttendees, + platformUsed = platformUsed, + willBeRecorded = willBeRecorded + ) + ) + if (eventCreationViewModel.goNext()) { navController.navigate("create_event_4") + } else { + val errors = eventCreationViewModel.uiState.value.validationErrors + eventDateError = errors["eventDates"].orEmpty() + startTimeError = errors["startTime"].orEmpty() + endTimeError = errors["endTime"].orEmpty() + registrationDeadlineError = errors["registrationDeadline"].orEmpty() + maxAttendeesError = errors["maxAttendees"].orEmpty() } }, enabled = areAllFieldsFilled, @@ -747,4 +776,4 @@ fun CreateEvent3Screen(navController: NavController) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent4Screen.kt b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent4Screen.kt index 1241071..735e458 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent4Screen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent4Screen.kt @@ -25,10 +25,20 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.example.talkeys_new.R import com.example.talkeys_new.screens.common.HomeTopBar +import com.talkeys.shared.presentation.events.EventCreationStep +import com.talkeys.shared.presentation.events.EventCreationViewModel +import com.talkeys.shared.presentation.events.EventPricingDraft @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateEvent4Screen(navController: NavController) { +fun CreateEvent4Screen( + navController: NavController, + eventCreationViewModel: EventCreationViewModel +) { + LaunchedEffect(eventCreationViewModel) { + eventCreationViewModel.moveToStep(EventCreationStep.Pricing) + } + // State variables for form fields var eventType by remember { mutableStateOf("") } var eventTypeDropdownExpanded by remember { mutableStateOf(false) } @@ -618,6 +628,7 @@ fun CreateEvent4Screen(navController: NavController) { // Previous Button OutlinedButton( onClick = { + eventCreationViewModel.goPrevious() navController.navigate("create_event_3") }, colors = ButtonDefaults.outlinedButtonColors( @@ -653,8 +664,22 @@ fun CreateEvent4Screen(navController: NavController) { // Next Button Button( onClick = { - if (validateForm() && areAllFieldsFilled) { + eventCreationViewModel.updatePricing( + EventPricingDraft( + eventType = eventType, + ticketPrice = ticketPrice, + discounts = discounts, + discountPercentage = discountPercentage, + qrCheckIn = qrCheckIn, + refundPolicy = refundPolicy + ) + ) + if (eventCreationViewModel.goNext()) { navController.navigate("create_event_5") + } else { + val errors = eventCreationViewModel.uiState.value.validationErrors + ticketPriceError = errors["ticketPrice"].orEmpty() + discountPercentageError = errors["discountPercentage"].orEmpty() } }, enabled = areAllFieldsFilled, @@ -685,4 +710,4 @@ fun CreateEvent4Screen(navController: NavController) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent5Screen.kt b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent5Screen.kt index 073a10c..56224cb 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent5Screen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent5Screen.kt @@ -1,5 +1,8 @@ package com.example.talkeys_new.screens.events.createEvent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -25,10 +28,20 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.example.talkeys_new.R import com.example.talkeys_new.screens.common.HomeTopBar +import com.talkeys.shared.presentation.events.EventAudienceDraft +import com.talkeys.shared.presentation.events.EventCreationStep +import com.talkeys.shared.presentation.events.EventCreationViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateEvent5Screen(navController: NavController) { +fun CreateEvent5Screen( + navController: NavController, + eventCreationViewModel: EventCreationViewModel +) { + LaunchedEffect(eventCreationViewModel) { + eventCreationViewModel.moveToStep(EventCreationStep.Audience) + } + // State variables for form fields var communityChat by remember { mutableStateOf("") } var communityChatDropdownExpanded by remember { mutableStateOf(false) } @@ -36,8 +49,20 @@ fun CreateEvent5Screen(navController: NavController) { var sponsorsDropdownExpanded by remember { mutableStateOf(false) } var audienceType by remember { mutableStateOf("") } var selectedFileName by remember { mutableStateOf("") } + var selectedFileUri by remember { mutableStateOf(null) } val context = LocalContext.current + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + selectedFileUri = uri + selectedFileName = uri?.lastPathSegment + ?.split("/") + ?.lastOrNull() + ?.split("?") + ?.firstOrNull() + ?: "" + } // Dropdown options val communityChatOptions = listOf("Yes", "No") @@ -317,8 +342,7 @@ fun CreateEvent5Screen(navController: NavController) { // File Upload Button Button( onClick = { - // TODO: Implement file picker - selectedFileName = "sample_deck.pdf" // Placeholder + filePickerLauncher.launch("*/*") }, colors = ButtonDefaults.buttonColors( containerColor = Color(0xFF7A2EC0), @@ -374,6 +398,7 @@ fun CreateEvent5Screen(navController: NavController) { // Previous Button OutlinedButton( onClick = { + eventCreationViewModel.goPrevious() navController.navigate("create_event_4") }, colors = ButtonDefaults.outlinedButtonColors( @@ -409,7 +434,15 @@ fun CreateEvent5Screen(navController: NavController) { // Next Button Button( onClick = { - if (areRequiredFieldsFilled) { + eventCreationViewModel.updateAudience( + EventAudienceDraft( + communityChat = communityChat, + sponsors = sponsors, + audienceType = audienceType, + sponsorDeck = selectedFileUri.toSharedImage(context, selectedFileName) + ) + ) + if (eventCreationViewModel.goNext()) { navController.navigate("create_event_6") } }, @@ -441,4 +474,4 @@ fun CreateEvent5Screen(navController: NavController) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent6Screen.kt b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent6Screen.kt index 67e96d9..023bbca 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent6Screen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/events/createEvent/CreateEvent6Screen.kt @@ -27,10 +27,20 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.example.talkeys_new.R import com.example.talkeys_new.screens.common.HomeTopBar +import com.talkeys.shared.presentation.events.EventCreationStep +import com.talkeys.shared.presentation.events.EventCreationViewModel +import com.talkeys.shared.presentation.events.EventReviewDraft @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateEvent6Screen(navController: NavController) { +fun CreateEvent6Screen( + navController: NavController, + eventCreationViewModel: EventCreationViewModel +) { + LaunchedEffect(eventCreationViewModel) { + eventCreationViewModel.moveToStep(EventCreationStep.Review) + } + // State variables for checkboxes var isInfoAccurate by remember { mutableStateOf(false) } var agreeToTerms by remember { mutableStateOf(false) } @@ -240,8 +250,16 @@ fun CreateEvent6Screen(navController: NavController) { // Submit Button Button( onClick = { - if (canSubmit) { + eventCreationViewModel.updateReview( + EventReviewDraft( + isInfoAccurate = isInfoAccurate, + agreeToTerms = agreeToTerms + ) + ) + if (eventCreationViewModel.requestSubmit()) { // TODO: Handle event submission logic here (API call, etc.) + eventCreationViewModel.markSubmissionHandled() + eventCreationViewModel.resetDraft() // Navigate to registration success screen navController.navigate("registration_success") { @@ -275,6 +293,7 @@ fun CreateEvent6Screen(navController: NavController) { // Previous Button OutlinedButton( onClick = { + eventCreationViewModel.goPrevious() navController.navigate("create_event_5") }, colors = ButtonDefaults.outlinedButtonColors( @@ -311,4 +330,4 @@ fun CreateEvent6Screen(navController: NavController) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/eventDetailScreen/EventDetailScreen.kt b/app/src/main/java/com/example/talkeys_new/screens/events/eventDetailScreen/EventDetailScreen.kt index 25f7bce..01f0453 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/events/eventDetailScreen/EventDetailScreen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/events/eventDetailScreen/EventDetailScreen.kt @@ -2,6 +2,7 @@ package com.example.talkeys_new.screens.events.eventDetailScreen import android.content.Context import android.content.Intent +import android.net.Uri import android.util.Log import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.* @@ -888,9 +889,12 @@ private fun RegisterButton( val eventId = event._id?.takeIf { it.isNotEmpty() } ?: return@clickable val eventName = event.name?.takeIf { it.isNotEmpty() } ?: "Unknown Event" val ticketPrice = event.ticketPrice ?: 0 + val encodedEventId = Uri.encode(eventId) + val encodedEventName = Uri.encode(eventName) + val encodedTicketPrice = Uri.encode(ticketPrice.toString()) try { - navController.navigate("payment/$eventId/$eventName/$ticketPrice") + navController.navigate("payment/$encodedEventId/$encodedEventName/$encodedTicketPrice") } catch (e: Exception) { Log.e("RegisterButton", "Navigation failed", e) } diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/mediator/EventMediator.kt b/app/src/main/java/com/example/talkeys_new/screens/events/mediator/EventMediator.kt deleted file mode 100644 index 468093a..0000000 --- a/app/src/main/java/com/example/talkeys_new/screens/events/mediator/EventMediator.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.example.talkeys_new.screens.events.mediator - -import com.example.talkeys_new.dataModels.EventResponse -import kotlinx.coroutines.flow.StateFlow - -/** - * Event Mediator interface that defines the contract for managing - * event-related communications and operations across different components - */ -interface EventMediator { - - // Event Data Operations - suspend fun fetchAllEvents(forceRefresh: Boolean = false) - suspend fun fetchEventById(eventId: String, forceRefresh: Boolean = false) - suspend fun refreshEvents() - - // Event State Management - val eventList: StateFlow> - val selectedEvent: StateFlow - val isLoading: StateFlow - val errorMessage: StateFlow - - // Event Actions - suspend fun likeEvent(eventId: String): Boolean - suspend fun unlikeEvent(eventId: String): Boolean - suspend fun registerForEvent(eventId: String): Boolean - suspend fun shareEvent(event: EventResponse): Boolean - - // Event Creation Flow Coordination - fun startEventCreation() - fun proceedToNextStep(stepData: Map) - fun goToPreviousStep() - fun saveEventDraft(stepData: Map) - fun submitEvent(eventData: Map): Boolean - fun cancelEventCreation() - - // Filter and Search Operations - fun toggleEventFilter() - fun applySearchFilter(query: String) - fun applyCategoryFilter(category: String) - fun clearAllFilters() - - // Navigation Coordination - fun navigateToEventDetail(eventId: String) - fun navigateToEventCreation() - fun navigateToEventList() - - // Error Handling - fun clearErrors() - fun handleError(error: Throwable, context: String) - - // Cleanup - fun cleanup() -} - -/** - * Event Mediator Component interface for components that want to participate - * in the event mediation system - */ -interface EventMediatorComponent { - fun setMediator(mediator: EventMediator) - fun getMediator(): EventMediator? -} - -/** - * Abstract base class for Event Mediator Components - */ -abstract class BaseEventMediatorComponent : EventMediatorComponent { - private var _mediator: EventMediator? = null - - override fun setMediator(mediator: EventMediator) { - this._mediator = mediator - } - - override fun getMediator(): EventMediator? = _mediator - - protected fun getMediatorInstance(): EventMediator? = _mediator -} - -/** - * Event Creation Step Data class for the multi-step creation flow - */ -data class EventCreationStep( - val stepNumber: Int, - val stepName: String, - val data: Map = emptyMap(), - val isValid: Boolean = false, - val errors: List = emptyList() -) - -/** - * Event Action Result sealed class to represent operation results - */ -sealed class EventActionResult { - object Success : EventActionResult() - data class Error(val message: String, val exception: Throwable? = null) : EventActionResult() - object InProgress : EventActionResult() -} - -/** - * Event Operation Types enum for tracking operations - */ -enum class EventOperation { - FETCH_ALL, - FETCH_BY_ID, - LIKE, - UNLIKE, - REGISTER, - SHARE, - CREATE, - UPDATE, - DELETE, - FILTER, - SEARCH -} - -/** - * Event Listener interface for components that want to listen to event changes - */ -interface EventListener { - fun onEventUpdated(event: EventResponse) - fun onEventDeleted(eventId: String) - fun onEventLiked(eventId: String, isLiked: Boolean) - fun onEventRegistered(eventId: String, isRegistered: Boolean) - fun onEventsRefreshed(events: List) - fun onError(operation: EventOperation, error: Throwable) -} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/mediator/EventMediatorImpl.kt b/app/src/main/java/com/example/talkeys_new/screens/events/mediator/EventMediatorImpl.kt deleted file mode 100644 index 39d5b26..0000000 --- a/app/src/main/java/com/example/talkeys_new/screens/events/mediator/EventMediatorImpl.kt +++ /dev/null @@ -1,491 +0,0 @@ -package com.example.talkeys_new.screens.events.mediator - -import android.content.Context -import android.content.Intent -import android.util.Log -import androidx.navigation.NavController -import com.example.talkeys_new.dataModels.EventResponse -import com.example.talkeys_new.screens.events.EventsRepository -import com.example.talkeys_new.utils.Result -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* -import java.util.concurrent.ConcurrentHashMap - -/** - * Concrete implementation of EventMediator that coordinates all event-related operations, - * communications, and state management across different components - */ -class EventMediatorImpl( - private val repository: EventsRepository, - private val context: Context, - private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) -) : EventMediator { - - companion object { - private const val TAG = "EventMediatorImpl" - } - - // Navigation controller (set externally) - private var navController: NavController? = null - - // Event listeners registry - private val listeners = ConcurrentHashMap() - - // State flows for event data - private val _eventList = MutableStateFlow>(emptyList()) - override val eventList: StateFlow> = _eventList.asStateFlow() - - private val _selectedEvent = MutableStateFlow(null) - override val selectedEvent: StateFlow = _selectedEvent.asStateFlow() - - private val _isLoading = MutableStateFlow(false) - override val isLoading: StateFlow = _isLoading.asStateFlow() - - private val _errorMessage = MutableStateFlow(null) - override val errorMessage: StateFlow = _errorMessage.asStateFlow() - - // Event creation flow state - private val _currentCreationStep = MutableStateFlow(0) - val currentCreationStep: StateFlow = _currentCreationStep.asStateFlow() - - private val _creationSteps = MutableStateFlow>(emptyList()) - val creationSteps: StateFlow> = _creationSteps.asStateFlow() - - // Filter state - private val _showLiveEvents = MutableStateFlow(true) - val showLiveEvents: StateFlow = _showLiveEvents.asStateFlow() - - private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery.asStateFlow() - - private val _selectedCategory = MutableStateFlow(null) - val selectedCategory: StateFlow = _selectedCategory.asStateFlow() - - // Filtered events based on current filters - private val _filteredEvents = MutableStateFlow>(emptyList()) - val filteredEvents: StateFlow> = _filteredEvents.asStateFlow() - - // Liked events tracking (local state) - private val _likedEvents = MutableStateFlow>(emptySet()) - val likedEvents: StateFlow> = _likedEvents.asStateFlow() - - private var isCurrentlyFetching = false - - init { - // Setup reactive filtering - combine( - eventList, - showLiveEvents, - searchQuery, - selectedCategory - ) { events, showLive, query, category -> - applyFilters(events, showLive, query, category) - }.onEach { filtered -> - _filteredEvents.value = filtered - }.launchIn(coroutineScope) - } - - // Public method to set navigation controller - fun setNavController(navController: NavController) { - this.navController = navController - } - - // Event listener management - fun addListener(key: String, listener: EventListener) { - listeners[key] = listener - } - - fun removeListener(key: String) { - listeners.remove(key) - } - - // ===== Event Data Operations ===== - - override suspend fun fetchAllEvents(forceRefresh: Boolean) { - if (isCurrentlyFetching && !forceRefresh) { - Log.d(TAG, "Already fetching events, skipping duplicate request") - return - } - - try { - isCurrentlyFetching = true - _isLoading.value = true - _errorMessage.value = null - - Log.d(TAG, "Fetching all events (forceRefresh: $forceRefresh)") - - when (val result = repository.getAllEvents(forceRefresh)) { - is Result.Success -> { - val events = result.data - _eventList.value = events - _errorMessage.value = null - - // Notify listeners - notifyListeners { it.onEventsRefreshed(events) } - - Log.d(TAG, "Successfully fetched ${events.size} events") - } - is Result.Error -> { - val errorMsg = result.message - _errorMessage.value = errorMsg - handleError(result.exception ?: Exception(errorMsg), "fetchAllEvents") - Log.e(TAG, "Error fetching events: $errorMsg") - } - is Result.Loading -> { - // Already handled by setting _isLoading to true - } - } - } catch (e: Exception) { - Log.e(TAG, "Exception in fetchAllEvents", e) - handleError(e, "fetchAllEvents") - } finally { - _isLoading.value = false - isCurrentlyFetching = false - } - } - - override suspend fun fetchEventById(eventId: String, forceRefresh: Boolean) { - if (eventId.isBlank()) { - handleError(IllegalArgumentException("Event ID cannot be empty"), "fetchEventById") - return - } - - try { - _isLoading.value = true - _errorMessage.value = null - - Log.d(TAG, "Fetching event by ID: $eventId (forceRefresh: $forceRefresh)") - - when (val result = repository.getEventById(eventId, forceRefresh)) { - is Result.Success -> { - val event = result.data - _selectedEvent.value = event - - // Also update the event in the list if it exists - val currentList = _eventList.value.toMutableList() - val index = currentList.indexOfFirst { it._id == eventId } - if (index != -1) { - currentList[index] = event - _eventList.value = currentList - } - - // Notify listeners - notifyListeners { it.onEventUpdated(event) } - - Log.d(TAG, "Successfully fetched event: ${event.name}") - } - is Result.Error -> { - val errorMsg = result.message - _errorMessage.value = errorMsg - handleError(result.exception ?: Exception(errorMsg), "fetchEventById") - Log.e(TAG, "Error fetching event by ID: $errorMsg") - } - is Result.Loading -> { - // Already handled by setting _isLoading to true - } - } - } catch (e: Exception) { - Log.e(TAG, "Exception in fetchEventById", e) - handleError(e, "fetchEventById") - } finally { - _isLoading.value = false - } - } - - override suspend fun refreshEvents() { - Log.d(TAG, "Refreshing events") - fetchAllEvents(forceRefresh = true) - } - - // ===== Event Actions ===== - - override suspend fun likeEvent(eventId: String): Boolean { - return try { - // Update local state immediately for better UX - val currentLiked = _likedEvents.value.toMutableSet() - currentLiked.add(eventId) - _likedEvents.value = currentLiked - - // TODO: Implement API call for liking event - // For now, just simulate success - - notifyListeners { it.onEventLiked(eventId, true) } - Log.d(TAG, "Event liked: $eventId") - true - } catch (e: Exception) { - Log.e(TAG, "Error liking event: $eventId", e) - handleError(e, "likeEvent") - false - } - } - - override suspend fun unlikeEvent(eventId: String): Boolean { - return try { - // Update local state immediately - val currentLiked = _likedEvents.value.toMutableSet() - currentLiked.remove(eventId) - _likedEvents.value = currentLiked - - // TODO: Implement API call for unliking event - // For now, just simulate success - - notifyListeners { it.onEventLiked(eventId, false) } - Log.d(TAG, "Event unliked: $eventId") - true - } catch (e: Exception) { - Log.e(TAG, "Error unliking event: $eventId", e) - handleError(e, "unlikeEvent") - false - } - } - - override suspend fun registerForEvent(eventId: String): Boolean { - return try { - // TODO: Implement API call for event registration - // For now, just simulate success - - notifyListeners { it.onEventRegistered(eventId, true) } - Log.d(TAG, "Registered for event: $eventId") - true - } catch (e: Exception) { - Log.e(TAG, "Error registering for event: $eventId", e) - handleError(e, "registerForEvent") - false - } - } - - override suspend fun shareEvent(event: EventResponse): Boolean { - return try { - val shareIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, "Check out this event: ${event.name}") - putExtra( - Intent.EXTRA_TEXT, - "I found this interesting event: ${event.name}\n\n" + - "${event.eventDescription ?: "No description available"}\n\n" + - "Date: ${event.startDate}\n" + - "Time: ${event.startTime}\n" + - "Location: ${event.location ?: "Online"}" - ) - } - - val chooserIntent = Intent.createChooser(shareIntent, "Share Event") - chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(chooserIntent) - - Log.d(TAG, "Event shared: ${event.name}") - true - } catch (e: Exception) { - Log.e(TAG, "Error sharing event: ${event.name}", e) - handleError(e, "shareEvent") - false - } - } - - // ===== Event Creation Flow Coordination ===== - - override fun startEventCreation() { - Log.d(TAG, "Starting event creation flow") - _currentCreationStep.value = 1 - _creationSteps.value = initializeCreationSteps() - navigateToEventCreation() - } - - override fun proceedToNextStep(stepData: Map) { - val currentStep = _currentCreationStep.value - val steps = _creationSteps.value.toMutableList() - - if (currentStep <= steps.size) { - // Update current step data - steps[currentStep - 1] = steps[currentStep - 1].copy( - data = stepData, - isValid = true - ) - - // Move to next step if not the last one - if (currentStep < steps.size) { - _currentCreationStep.value = currentStep + 1 - } - - _creationSteps.value = steps - Log.d(TAG, "Proceeded to step ${_currentCreationStep.value}") - } - } - - override fun goToPreviousStep() { - val currentStep = _currentCreationStep.value - if (currentStep > 1) { - _currentCreationStep.value = currentStep - 1 - Log.d(TAG, "Moved back to step ${_currentCreationStep.value}") - } - } - - override fun saveEventDraft(stepData: Map) { - // TODO: Implement draft saving to local storage or cache - Log.d(TAG, "Event draft saved") - } - - override fun submitEvent(eventData: Map): Boolean { - return try { - // TODO: Implement API call to create event - Log.d(TAG, "Event submitted successfully") - - // Reset creation flow - _currentCreationStep.value = 0 - _creationSteps.value = emptyList() - - // Navigate back to event list - navigateToEventList() - - // Refresh events to show the new one - coroutineScope.launch { - refreshEvents() - } - - true - } catch (e: Exception) { - Log.e(TAG, "Error submitting event", e) - handleError(e, "submitEvent") - false - } - } - - override fun cancelEventCreation() { - Log.d(TAG, "Event creation cancelled") - _currentCreationStep.value = 0 - _creationSteps.value = emptyList() - navigateToEventList() - } - - // ===== Filter and Search Operations ===== - - override fun toggleEventFilter() { - _showLiveEvents.value = !_showLiveEvents.value - Log.d(TAG, "Filter toggled - Show live events: ${_showLiveEvents.value}") - } - - override fun applySearchFilter(query: String) { - _searchQuery.value = query - Log.d(TAG, "Search filter applied: $query") - } - - override fun applyCategoryFilter(category: String) { - _selectedCategory.value = category - Log.d(TAG, "Category filter applied: $category") - } - - override fun clearAllFilters() { - _searchQuery.value = "" - _selectedCategory.value = null - _showLiveEvents.value = true - Log.d(TAG, "All filters cleared") - } - - // ===== Navigation Coordination ===== - - override fun navigateToEventDetail(eventId: String) { - navController?.navigate("eventDetail/$eventId") - Log.d(TAG, "Navigating to event detail: $eventId") - } - - override fun navigateToEventCreation() { - navController?.navigate("createEvent1") - Log.d(TAG, "Navigating to event creation") - } - - override fun navigateToEventList() { - navController?.navigate("exploreEvents") - Log.d(TAG, "Navigating to event list") - } - - // ===== Error Handling ===== - - override fun clearErrors() { - _errorMessage.value = null - Log.d(TAG, "Errors cleared") - } - - override fun handleError(error: Throwable, context: String) { - val errorMessage = "Error in $context: ${error.localizedMessage ?: error.message ?: "Unknown error"}" - _errorMessage.value = errorMessage - - // Determine operation type for listeners - val operation = when (context) { - "fetchAllEvents" -> EventOperation.FETCH_ALL - "fetchEventById" -> EventOperation.FETCH_BY_ID - "likeEvent" -> EventOperation.LIKE - "unlikeEvent" -> EventOperation.UNLIKE - "registerForEvent" -> EventOperation.REGISTER - "shareEvent" -> EventOperation.SHARE - "submitEvent" -> EventOperation.CREATE - else -> EventOperation.FETCH_ALL - } - - notifyListeners { it.onError(operation, error) } - Log.e(TAG, "Error handled: $errorMessage", error) - } - - // ===== Cleanup ===== - - override fun cleanup() { - Log.d(TAG, "Cleaning up EventMediator") - listeners.clear() - coroutineScope.cancel() - } - - // ===== Private Helper Methods ===== - - private fun applyFilters( - events: List, - showLive: Boolean, - query: String, - category: String? - ): List { - var filtered = events - - // Filter by live/past status - filtered = if (showLive) { - filtered.filter { it.isLive == true } - } else { - filtered.filter { it.isLive == false } - } - - // Filter by search query - if (query.isNotBlank()) { - filtered = filtered.filter { event -> - event.name.contains(query, ignoreCase = true) || - event.eventDescription?.contains(query, ignoreCase = true) == true || - event.category.contains(query, ignoreCase = true) - } - } - - // Filter by category - category?.let { cat -> - filtered = filtered.filter { it.category == cat } - } - - return filtered - } - - private fun initializeCreationSteps(): List { - return listOf( - EventCreationStep(1, "Organizer Information"), - EventCreationStep(2, "Event Details"), - EventCreationStep(3, "Date and Time"), - EventCreationStep(4, "Location and Mode"), - EventCreationStep(5, "Pricing and Seats"), - EventCreationStep(6, "Review and Submit") - ) - } - - private inline fun notifyListeners(action: (EventListener) -> Unit) { - listeners.values.forEach { listener -> - try { - action(listener) - } catch (e: Exception) { - Log.e(TAG, "Error notifying listener", e) - } - } - } -} diff --git a/app/src/main/java/com/example/talkeys_new/screens/events/mediator/EventMediatorProvider.kt b/app/src/main/java/com/example/talkeys_new/screens/events/mediator/EventMediatorProvider.kt deleted file mode 100644 index 7744bc0..0000000 --- a/app/src/main/java/com/example/talkeys_new/screens/events/mediator/EventMediatorProvider.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.example.talkeys_new.screens.events.mediator - -import android.content.Context -import androidx.navigation.NavController -import com.example.talkeys_new.api.RetrofitClient -import com.example.talkeys_new.screens.events.EventApiService -import com.example.talkeys_new.screens.events.EventsRepository -import com.example.talkeys_new.screens.events.provideEventApiService -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob - -/** - * Singleton provider for EventMediator instances. - * This ensures we have a single mediator instance across the application - * and provides proper lifecycle management. - */ -object EventMediatorProvider { - - @Volatile - private var INSTANCE: EventMediatorImpl? = null - - /** - * Get the singleton EventMediator instance - * @param context Application context - * @return EventMediator instance - */ - fun getMediator(context: Context): EventMediator { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: createMediator(context).also { INSTANCE = it } - } - } - - /** - * Set the navigation controller for the mediator - * @param navController Navigation controller instance - */ - fun setNavController(navController: NavController) { - INSTANCE?.setNavController(navController) - } - - /** - * Add a listener to the mediator - * @param key Unique key for the listener - * @param listener EventListener instance - */ - fun addListener(key: String, listener: EventListener) { - INSTANCE?.addListener(key, listener) - } - - /** - * Remove a listener from the mediator - * @param key Unique key for the listener - */ - fun removeListener(key: String) { - INSTANCE?.removeListener(key) - } - - /** - * Clear the singleton instance (useful for testing or app restart) - */ - fun clearInstance() { - INSTANCE?.cleanup() - INSTANCE = null - } - - /** - * Create a new EventMediator instance - */ - private fun createMediator(context: Context): EventMediatorImpl { - // Create API service using the existing provider function - val apiService = provideEventApiService(context) - - // Create repository - val repository = EventsRepository(apiService) - - // Create coroutine scope for mediator - val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - - // Create and return mediator - return EventMediatorImpl(repository, context.applicationContext, scope) - } -} - -/** - * Extension function to easily get the mediator from any context - */ -fun Context.getEventMediator(): EventMediator = EventMediatorProvider.getMediator(this) - -/** - * Abstract base class for components that use the EventMediator - * Provides common functionality for mediator-aware components - */ -abstract class EventMediatorAware(protected val context: Context) { - - protected val mediator: EventMediator by lazy { - EventMediatorProvider.getMediator(context) - } - - /** - * Register this component as a listener with a unique key - */ - protected fun registerAsListener(key: String, listener: EventListener) { - EventMediatorProvider.addListener(key, listener) - } - - /** - * Unregister this component as a listener - */ - protected fun unregisterAsListener(key: String) { - EventMediatorProvider.removeListener(key) - } - - /** - * Called when this component is created/initialized - */ - abstract fun onComponentCreated() - - /** - * Called when this component is destroyed/cleaned up - */ - abstract fun onComponentDestroyed() -} diff --git a/app/src/main/java/com/example/talkeys_new/screens/payment/EventPaymentScreen.kt b/app/src/main/java/com/example/talkeys_new/screens/payment/EventPaymentScreen.kt index 6f1f181..c0a898a 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/payment/EventPaymentScreen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/payment/EventPaymentScreen.kt @@ -21,14 +21,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import com.example.talkeys_new.MainActivity import com.example.talkeys_new.R -import com.example.talkeys_new.utils.PhonePePaymentManager -import android.util.Log import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.CoroutineScope @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -242,7 +236,25 @@ private fun PhonePePaymentSection( ) { val context = LocalContext.current var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val paymentViewModel = sharedPaymentCheckoutViewModel() + val checkoutState by paymentViewModel.checkoutState.collectAsState() + val errorMessage = checkoutState.errorMessage + + LaunchedEffect(checkoutState.checkoutData) { + checkoutState.checkoutData?.let { checkout -> + isLoading = false + val encodedUrl = android.net.Uri.encode(checkout.paymentUrl) + navController.navigate( + "webview_payment/$encodedUrl/${checkout.merchantOrderId}/${checkout.passId}" + ) + paymentViewModel.clearCheckout() + } + } + + LaunchedEffect(checkoutState.isLoading) { + isLoading = checkoutState.isLoading + } Card( modifier = Modifier.fillMaxWidth(), @@ -337,81 +349,26 @@ private fun PhonePePaymentSection( Button( onClick = { isLoading = true - errorMessage = null onPaymentInitiated() // Prepare payment data val passType = determinePassType(amount) val friends = getUserSelectedFriends() - // Create payment order and open WebView - kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { - try { - // Get auth token - val tokenManager = com.example.talkeys_new.screens.authentication.TokenManager(context) - val tokenResult = tokenManager.getToken() - - val authToken = when (tokenResult) { - is com.example.talkeys_new.utils.Result.Success -> { - tokenResult.data?.takeIf { it.isNotEmpty() } - } - else -> null - } - - if (authToken == null) { - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - isLoading = false - errorMessage = "Please login to continue" - } - return@launch - } - - // Call backend to create payment order - val paymentRepository = org.koin.core.context.GlobalContext.get().get() - val result = paymentRepository.bookTicket(eventId, passType, friends, authToken) - - result.fold( - onSuccess = { paymentData -> - Log.d("WebViewPayment", "Payment order created") - - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - isLoading = false - - // Construct PhonePe payment URL from token - // Backend sends token, we need to construct the full URL - val paymentUrl = if (paymentData.token.startsWith("http")) { - // Token is already a full URL - paymentData.token - } else { - // Construct URL from token (UAT/Sandbox environment) - // URL-encode the token to preserve + characters (they become %2B) - val encodedToken = java.net.URLEncoder.encode(paymentData.token, "UTF-8") - "https://mercury-t2.phonepe.com/transact/pg?token=$encodedToken" - } - - // Navigate to WebView payment screen - // Use Uri.encode() which preserves the already-encoded characters - val encodedUrl = android.net.Uri.encode(paymentUrl) - navController.navigate( - "webview_payment/$encodedUrl/${paymentData.merchantOrderId}/${paymentData.passId}" - ) - } - }, - onFailure = { exception -> - Log.e("WebViewPayment", "Failed to create payment order: ${exception.message}") - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - isLoading = false - errorMessage = "Failed to create payment: ${exception.message}" - } - } - ) - } catch (e: Exception) { - Log.e("WebViewPayment", "Error: ${e.message}") - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - isLoading = false - errorMessage = "Error: ${e.message}" - } + scope.launch { + val tokenManager = com.example.talkeys_new.screens.authentication.TokenManager(context) + val tokenResult = tokenManager.getToken() + val authToken = when (tokenResult) { + is com.example.talkeys_new.utils.Result.Success -> tokenResult.data?.takeIf { it.isNotBlank() } + else -> null } + + paymentViewModel.startCheckout( + eventId = eventId, + passType = passType, + friends = friends, + authToken = authToken + ) } }, modifier = Modifier @@ -448,11 +405,7 @@ private fun PhonePePaymentSection( * Backend accepts: "VIP", "General", "Staff" (default: "General") */ private fun determinePassType(amount: Double): String { - return when { - amount >= 500 -> "VIP" // Higher amounts get VIP - amount > 0 -> "General" // Regular paid events get General - else -> "General" // Default to General - } + return "General" } /** @@ -507,4 +460,4 @@ private fun PaymentSecurityInfo() { ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/talkeys_new/screens/payment/PaymentVerificationScreen.kt b/app/src/main/java/com/example/talkeys_new/screens/payment/PaymentVerificationScreen.kt index 7fc0d99..c051115 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/payment/PaymentVerificationScreen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/payment/PaymentVerificationScreen.kt @@ -22,7 +22,6 @@ import com.example.talkeys_new.screens.authentication.TokenManager import android.util.Log import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import retrofit2.HttpException /** * Payment Verification Screen @@ -37,19 +36,25 @@ fun PaymentVerificationScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val paymentViewModel = sharedPaymentCheckoutViewModel() + val sharedVerificationState by paymentViewModel.verificationState.collectAsState() var verificationState by remember { mutableStateOf(VerificationState.Checking) } var retryCount by remember { mutableStateOf(0) } val maxRetries = 3 + + suspend fun requestVerification() { + val authToken = getPaymentAuthToken(context) + paymentViewModel.verifyPaymentStatus(merchantOrderId, authToken) + } // Auto-verify on screen load LaunchedEffect(merchantOrderId) { - verifyPayment( - context = context, - merchantOrderId = merchantOrderId, - passId = passId, - onResult = { state -> verificationState = state } - ) + requestVerification() + } + + LaunchedEffect(sharedVerificationState) { + verificationState = sharedVerificationState.toVerificationState(fallbackPassId = passId) } Scaffold( @@ -211,12 +216,7 @@ fun PaymentVerificationScreen( verificationState = VerificationState.Checking scope.launch { delay(1000) - verifyPayment( - context = context, - merchantOrderId = merchantOrderId, - passId = passId, - onResult = { state -> verificationState = state } - ) + requestVerification() } } }, @@ -279,12 +279,7 @@ fun PaymentVerificationScreen( verificationState = VerificationState.Checking scope.launch { delay(2000) - verifyPayment( - context = context, - merchantOrderId = merchantOrderId, - passId = passId, - onResult = { state -> verificationState = state } - ) + requestVerification() } }, modifier = Modifier.weight(1f), @@ -345,12 +340,7 @@ fun PaymentVerificationScreen( verificationState = VerificationState.Checking scope.launch { delay(1000) - verifyPayment( - context = context, - merchantOrderId = merchantOrderId, - passId = passId, - onResult = { state -> verificationState = state } - ) + requestVerification() } }, modifier = Modifier.weight(1f), @@ -381,56 +371,32 @@ sealed class VerificationState { data class Error(val message: String) : VerificationState() } -/** - * Verify payment status with backend - */ -private suspend fun verifyPayment( - context: android.content.Context, - merchantOrderId: String, - passId: String, - onResult: (VerificationState) -> Unit -) { +private suspend fun getPaymentAuthToken(context: android.content.Context): String? { try { - Log.d("PaymentVerification", "Verifying payment status") - - // Get auth token val tokenManager = TokenManager(context) val tokenResult = tokenManager.getToken() - - val authToken = when (tokenResult) { - is com.example.talkeys_new.utils.Result.Success -> tokenResult.data + + return when (tokenResult) { + is com.example.talkeys_new.utils.Result.Success -> tokenResult.data?.takeIf { it.isNotBlank() } else -> null } - - // Call backend API to check status - val paymentRepository = org.koin.core.context.GlobalContext.get().get() - val result = paymentRepository.verifyPaymentStatus(merchantOrderId, authToken) - - result.fold( - onSuccess = { statusData -> - Log.d("PaymentVerification", "Status: ${statusData.paymentStatus}") - when (statusData.paymentStatus.uppercase()) { - "COMPLETED" -> { - onResult(VerificationState.Success(statusData.passId, statusData.passUUID)) - } - "PENDING" -> { - onResult(VerificationState.Pending) - } - "FAILED" -> { - onResult(VerificationState.Failed("Payment was not successful")) - } - else -> { - onResult(VerificationState.Error("Unknown payment status: ${statusData.paymentStatus}")) - } - } - }, - onFailure = { exception -> - Log.e("PaymentVerification", "Error: ${exception.message}") - onResult(VerificationState.Error(exception.message ?: "Failed to verify payment")) - } - ) } catch (e: Exception) { - Log.e("PaymentVerification", "Exception: ${e.message}") - onResult(VerificationState.Error(e.message ?: "An error occurred")) + Log.e("PaymentVerification", "Token read failed: ${e.message}") + return null + } +} + +private fun com.talkeys.shared.presentation.payment.PaymentVerificationUiState.toVerificationState( + fallbackPassId: String +): VerificationState { + if (isLoading) return VerificationState.Checking + errorMessage?.let { return VerificationState.Error(it) } + + return when (status?.uppercase()) { + "COMPLETED" -> VerificationState.Success(passId ?: fallbackPassId, passUUID) + "PENDING" -> VerificationState.Pending + "FAILED" -> VerificationState.Failed("Payment was not successful") + null -> VerificationState.Checking + else -> VerificationState.Error("Unknown payment status: $status") } } diff --git a/app/src/main/java/com/example/talkeys_new/screens/payment/SharedPaymentViewModelProvider.kt b/app/src/main/java/com/example/talkeys_new/screens/payment/SharedPaymentViewModelProvider.kt new file mode 100644 index 0000000..6f08a0a --- /dev/null +++ b/app/src/main/java/com/example/talkeys_new/screens/payment/SharedPaymentViewModelProvider.kt @@ -0,0 +1,15 @@ +package com.example.talkeys_new.screens.payment + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import com.talkeys.shared.data.payment.PaymentRepository +import com.talkeys.shared.presentation.payment.PaymentCheckoutViewModel +import com.talkeys.shared.presentation.payment.PaymentCheckoutViewModelFactory +import org.koin.compose.koinInject + +@Composable +fun sharedPaymentCheckoutViewModel( + repository: PaymentRepository = koinInject() +): PaymentCheckoutViewModel = viewModel( + factory = PaymentCheckoutViewModelFactory(repository) +) diff --git a/app/src/main/java/com/example/talkeys_new/screens/payment/WebViewPaymentScreen.kt b/app/src/main/java/com/example/talkeys_new/screens/payment/WebViewPaymentScreen.kt index 8232b9a..f1dcb5f 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/payment/WebViewPaymentScreen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/payment/WebViewPaymentScreen.kt @@ -313,11 +313,7 @@ private fun checkCallbackUrl( // Success callback url.contains("/ticket/success") || url.contains("payment/success") -> { Log.d("WebViewPayment", "Success callback detected") - // Extract passUUID if present - val passUUID = extractQueryParam(url, "uuid") ?: extractQueryParam(url, "passUUID") - navController.navigate("registration_success") { - popUpTo(0) { inclusive = false } - } + navController.navigate("payment_verification/$merchantOrderId/$passId") true } @@ -346,15 +342,3 @@ private fun checkCallbackUrl( else -> false } } - -/** - * Extract query parameter from URL - */ -private fun extractQueryParam(url: String, param: String): String? { - return try { - val uri = android.net.Uri.parse(url) - uri.getQueryParameter(param) - } catch (e: Exception) { - null - } -} diff --git a/app/src/main/java/com/example/talkeys_new/utils/API1_CreateOrder.kt b/app/src/main/java/com/example/talkeys_new/utils/API1_CreateOrder.kt deleted file mode 100644 index f8de6e8..0000000 --- a/app/src/main/java/com/example/talkeys_new/utils/API1_CreateOrder.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.example.talkeys_new.utils - -/** - * ๐Ÿ”ฅ API #1: CREATE ORDER (MOST IMPORTANT) - * - * This is what mobile app calls BEFORE starting payment - * - * ๐Ÿ“ฑ MOBILE APP WILL SEND: - * POST /api/payments/create-order - * { - * "eventId": "64f8a1b2c3d4e5f6789012ab", - * "userId": "user123", - * "amount": 500, - * "currency": "INR", - * "eventName": "Tech Conference 2024" - * } - * - * ๐Ÿ”ง WHAT YOU NEED TO DO: - * - * 1. Validate the request (user exists, event exists, amount correct) - * 2. Generate unique merchantTransactionId (e.g., "TXN_" + timestamp) - * 3. Call PhonePe Create Order API - * 4. Return token and orderId to mobile app - * - * ๐Ÿ“ค PHONEPE API CALL YOU MAKE: - * POST https://api-preprod.phonepe.com/apis/pg-sandbox/pg/v1/pay - * Headers: - * - Content-Type: application/json - * - X-VERIFY: SHA256(base64_payload + "/pg/v1/pay" + client_secret) + "###" + "1" - * - * Body (base64 encoded): - * { - * "merchantId": "SU2504181253408025787154", - * "merchantTransactionId": "TXN_1234567890", - * "merchantUserId": "user123", - * "amount": 50000, // Amount in paise (โ‚น500 = 50000 paise) - * "redirectUrl": "https://yourapp.com/payment/callback", - * "redirectMode": "POST", - * "callbackUrl": "https://yourapi.com/webhook/phonepe", - * "paymentInstrument": { - * "type": "PAY_PAGE" - * } - * } - * - * ๐Ÿ“ฅ PHONEPE WILL RETURN: - * { - * "success": true, - * "code": "PAYMENT_INITIATED", - * "message": "Payment initiated", - * "data": { - * "merchantId": "SU2504181253408025787154", - * "merchantTransactionId": "TXN_1234567890", - * "instrumentResponse": { - * "type": "PAY_PAGE", - * "redirectInfo": { - * "url": "https://mercury-t2.phonepe.com/transact/...", - * "method": "GET" - * } - * } - * } - * } - * - * ๐Ÿ“ฑ YOU RETURN TO MOBILE APP: - * { - * "success": true, - * "token": "extracted_from_phonepe_url", - * "orderId": "TXN_1234567890", - * "message": "Order created successfully" - * } - */ \ No newline at end of file diff --git a/app/src/main/java/com/example/talkeys_new/utils/API2_OrderStatus.kt b/app/src/main/java/com/example/talkeys_new/utils/API2_OrderStatus.kt deleted file mode 100644 index 445c481..0000000 --- a/app/src/main/java/com/example/talkeys_new/utils/API2_OrderStatus.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.example.talkeys_new.utils - -/** - * ๐Ÿ” API #2: ORDER STATUS (CRITICAL FOR VERIFICATION) - * - * This is called AFTER payment to verify if it was successful - * - * ๐Ÿ“ฑ MOBILE APP WILL SEND: - * GET /api/payments/status/{orderId} - * - * ๐Ÿ”ง WHAT YOU NEED TO DO: - * - * 1. Take orderId from mobile app - * 2. Call PhonePe Order Status API - * 3. Update payment status in your database - * 4. Return final status to mobile app - * - * ๐Ÿ“ค PHONEPE API CALL YOU MAKE: - * GET https://api-preprod.phonepe.com/apis/pg-sandbox/pg/v1/status/{merchantId}/{orderId} - * Headers: - * - Content-Type: application/json - * - X-VERIFY: SHA256("/pg/v1/status/{merchantId}/{orderId}" + client_secret) + "###" + "1" - * - * ๐Ÿ“ฅ PHONEPE WILL RETURN: - * { - * "success": true, - * "code": "PAYMENT_SUCCESS", - * "message": "Your payment is successful.", - * "data": { - * "merchantId": "SU2504181253408025787154", - * "merchantTransactionId": "TXN_1234567890", - * "transactionId": "T2411251200000000000001", - * "amount": 50000, - * "state": "COMPLETED", - * "responseCode": "SUCCESS", - * "paymentInstrument": { - * "type": "UPI", - * "utr": "123456789012" - * } - * } - * } - * - * ๐Ÿ“ฑ YOU RETURN TO MOBILE APP: - * { - * "success": true, - * "paymentStatus": "SUCCESS", // SUCCESS, FAILED, PENDING - * "transactionId": "T2411251200000000000001", - * "amount": 500, - * "message": "Payment successful", - * "registrationStatus": "CONFIRMED" // Update user's event registration - * } - * - * ๐Ÿšจ IMPORTANT PAYMENT STATES: - * - COMPLETED + SUCCESS = Payment successful โœ… - * - FAILED = Payment failed โŒ - * - PENDING = Payment still processing โณ - */ \ No newline at end of file diff --git a/app/src/main/java/com/example/talkeys_new/utils/API3_Webhook.kt b/app/src/main/java/com/example/talkeys_new/utils/API3_Webhook.kt deleted file mode 100644 index dd1cfff..0000000 --- a/app/src/main/java/com/example/talkeys_new/utils/API3_Webhook.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.talkeys_new.utils - -/** - * ๐Ÿ”” API #3: WEBHOOK HANDLER (RECOMMENDED) - * - * PhonePe will call this when payment status changes - * - * ๐Ÿ“ฅ PHONEPE WILL SEND: - * POST https://yourapi.com/webhook/phonepe - * Headers: - * - Content-Type: application/json - * - X-VERIFY: checksum_to_verify - * - * Body: - * { - * "response": "base64_encoded_response" - * } - * - * ๐Ÿ”ง WHAT YOU NEED TO DO: - * - * 1. Verify the checksum (security) - * 2. Decode the base64 response - * 3. Update payment status in database - * 4. Send confirmation email/notification to user - * 5. Return success response to PhonePe - * - * ๐Ÿ“ค DECODED RESPONSE WILL BE: - * { - * "merchantId": "SU2504181253408025787154", - * "merchantTransactionId": "TXN_1234567890", - * "transactionId": "T2411251200000000000001", - * "amount": 50000, - * "state": "COMPLETED", - * "responseCode": "SUCCESS", - * "paymentInstrument": { - * "type": "UPI" - * } - * } - * - * ๐Ÿ“ฑ YOU RETURN TO PHONEPE: - * { - * "success": true, - * "message": "Webhook processed successfully" - * } - * - * ๐Ÿ”’ CHECKSUM VERIFICATION: - * calculated_checksum = SHA256(base64_response + client_secret) - * received_checksum = X-VERIFY header (remove ###1 suffix) - * if (calculated_checksum === received_checksum) { process_webhook() } - */ \ No newline at end of file diff --git a/app/src/main/java/com/example/talkeys_new/utils/BackendCodeExamples.kt b/app/src/main/java/com/example/talkeys_new/utils/BackendCodeExamples.kt deleted file mode 100644 index 62e7684..0000000 --- a/app/src/main/java/com/example/talkeys_new/utils/BackendCodeExamples.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.example.talkeys_new.utils - -/** - * ๐Ÿ’ป CODE EXAMPLES FOR BACKEND DEVELOPER - * - * Here are code snippets in different languages: - * - * ๐ŸŸข NODE.JS EXAMPLE: - * - * const crypto = require('crypto'); - * const axios = require('axios'); - * - * // Create Order API - * app.post('/api/payments/create-order', async (req, res) => { - * const { eventId, userId, amount, eventName } = req.body; - * - * const merchantTransactionId = `TXN_${Date.now()}`; - * const payload = { - * merchantId: 'SU2504181253408025787154', - * merchantTransactionId, - * merchantUserId: userId, - * amount: amount * 100, // Convert to paise - * redirectUrl: 'https://yourapp.com/payment/callback', - * redirectMode: 'POST', - * callbackUrl: 'https://yourapi.com/webhook/phonepe', - * paymentInstrument: { type: 'PAY_PAGE' } - * }; - * - * const base64Payload = Buffer.from(JSON.stringify(payload)).toString('base64'); - * const checksum = crypto.createHash('sha256') - * .update(base64Payload + '/pg/v1/pay' + 'YOUR_CLIENT_SECRET') - * .digest('hex') + '###1'; - * - * try { - * const response = await axios.post( - * 'https://api-preprod.phonepe.com/apis/pg-sandbox/pg/v1/pay', - * { request: base64Payload }, - * { headers: { 'Content-Type': 'application/json', 'X-VERIFY': checksum } } - * ); - * - * // Extract token from PhonePe response URL - * const token = extractTokenFromUrl(response.data.data.instrumentResponse.redirectInfo.url); - * - * res.json({ - * success: true, - * token: token, - * orderId: merchantTransactionId - * }); - * } catch (error) { - * res.status(500).json({ success: false, message: error.message }); - * } - * }); - * - * ๐Ÿ”ต PYTHON EXAMPLE: - * - * import hashlib - * import base64 - * import requests - * import json - * - * @app.route('/api/payments/create-order', methods=['POST']) - * def create_order(): - * data = request.json - * merchant_transaction_id = f"TXN_{int(time.time())}" - * - * payload = { - * "merchantId": "SU2504181253408025787154", - * "merchantTransactionId": merchant_transaction_id, - * "merchantUserId": data['userId'], - * "amount": data['amount'] * 100, - * "redirectUrl": "https://yourapp.com/payment/callback", - * "redirectMode": "POST", - * "callbackUrl": "https://yourapi.com/webhook/phonepe", - * "paymentInstrument": {"type": "PAY_PAGE"} - * } - * - * base64_payload = base64.b64encode(json.dumps(payload).encode()).decode() - * checksum_string = base64_payload + "/pg/v1/pay" + "YOUR_CLIENT_SECRET" - * checksum = hashlib.sha256(checksum_string.encode()).hexdigest() + "###1" - * - * headers = { - * "Content-Type": "application/json", - * "X-VERIFY": checksum - * } - * - * response = requests.post( - * "https://api-preprod.phonepe.com/apis/pg-sandbox/pg/v1/pay", - * json={"request": base64_payload}, - * headers=headers - * ) - * - * # Process response and return token + orderId - */ \ No newline at end of file diff --git a/app/src/main/java/com/example/talkeys_new/utils/PaymentIntegrationNotes.kt b/app/src/main/java/com/example/talkeys_new/utils/PaymentIntegrationNotes.kt deleted file mode 100644 index cc63b26..0000000 --- a/app/src/main/java/com/example/talkeys_new/utils/PaymentIntegrationNotes.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.example.talkeys_new.utils - -/** - * PhonePe Payment Integration Notes - * - * IMPORTANT: This is a basic integration setup. For production use, you need to: - * - * 1. BACKEND INTEGRATION: - * - Create a backend API endpoint to create PhonePe orders - * - The backend should call PhonePe's Create Order API - * - Return the token and orderId to your app - * - Never hardcode merchant credentials in the app - * - * 2. ORDER STATUS VERIFICATION: - * - After payment completion, always call Order Status API - * - The payment result from the app doesn't guarantee success - * - Only the Order Status API gives the final payment status - * - * 3. SECURITY: - * - Replace "MID" and "FLOW_ID" in TalkeysApplication.kt with real values - * - Use RELEASE environment for production - * - Set enableLogging = false in production - * - * 4. ERROR HANDLING: - * - Implement proper error handling for network failures - * - Handle payment timeouts and cancellations - * - Show appropriate user messages - * - * 5. TESTING: - * - Use SANDBOX environment for testing - * - Test with different payment methods (UPI, Cards, Wallets) - * - Test failure scenarios - * - * 6. USER EXPERIENCE: - * - Show loading states during payment - * - Provide clear success/failure messages - * - Allow users to retry failed payments - * - * Current Implementation Status: - * โœ… SDK Integration - * โœ… Basic Payment Flow - * โœ… UI Components - * โŒ Backend Integration (needs implementation) - * โŒ Order Status Verification (needs implementation) - * โŒ Production Configuration (needs real credentials) - */ - -object PaymentIntegrationNotes { - const val TODO_BACKEND_INTEGRATION = "Implement backend API for order creation" - const val TODO_ORDER_STATUS_CHECK = "Implement order status verification" - const val TODO_PRODUCTION_CONFIG = "Configure production credentials" - const val TODO_ERROR_HANDLING = "Implement comprehensive error handling" -} \ No newline at end of file diff --git a/app/src/main/java/com/example/talkeys_new/utils/PaymentMethodsGuide.kt b/app/src/main/java/com/example/talkeys_new/utils/PaymentMethodsGuide.kt deleted file mode 100644 index 8ad75d4..0000000 --- a/app/src/main/java/com/example/talkeys_new/utils/PaymentMethodsGuide.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.example.talkeys_new.utils - -/** - * PhonePe Payment Methods Guide - * - * When users click "Pay with PhonePe", they get access to ALL these payment methods: - * - * ๐Ÿฆ UPI PAYMENTS: - * โ”œโ”€โ”€ Google Pay (GPay) - * โ”œโ”€โ”€ Paytm UPI - * โ”œโ”€โ”€ PhonePe UPI - * โ”œโ”€โ”€ BHIM UPI - * โ”œโ”€โ”€ Amazon Pay UPI - * โ”œโ”€โ”€ WhatsApp Pay - * โ”œโ”€โ”€ Bank UPI apps (SBI Pay, HDFC PayZapp, etc.) - * โ””โ”€โ”€ Any UPI-enabled app - * - * ๐Ÿ’ณ CREDIT/DEBIT CARDS: - * โ”œโ”€โ”€ Visa - * โ”œโ”€โ”€ Mastercard - * โ”œโ”€โ”€ RuPay - * โ”œโ”€โ”€ American Express - * โ”œโ”€โ”€ Diners Club - * โ””โ”€โ”€ All major bank cards - * - * ๐Ÿ›๏ธ NET BANKING: - * โ”œโ”€โ”€ State Bank of India - * โ”œโ”€โ”€ HDFC Bank - * โ”œโ”€โ”€ ICICI Bank - * โ”œโ”€โ”€ Axis Bank - * โ”œโ”€โ”€ Kotak Mahindra Bank - * โ”œโ”€โ”€ Punjab National Bank - * โ”œโ”€โ”€ Bank of Baroda - * โ””โ”€โ”€ 100+ other banks - * - * ๐Ÿ’ฐ DIGITAL WALLETS: - * โ”œโ”€โ”€ PhonePe Wallet - * โ”œโ”€โ”€ Paytm Wallet - * โ”œโ”€โ”€ Amazon Pay Balance - * โ”œโ”€โ”€ Mobikwik - * โ”œโ”€โ”€ Freecharge - * โ””โ”€โ”€ Other wallet providers - * - * ๐Ÿ›’ BUY NOW PAY LATER: - * โ”œโ”€โ”€ Simpl - * โ”œโ”€โ”€ LazyPay - * โ”œโ”€โ”€ PayLater by ICICI - * โ”œโ”€โ”€ Flipkart Pay Later - * โ””โ”€โ”€ Other BNPL providers - * - * ๐Ÿ“ฑ USER EXPERIENCE: - * - * Step 1: User clicks "Pay โ‚นX with PhonePe" - * Step 2: PhonePe opens (app or web) - * Step 3: User sees ALL payment options above - * Step 4: User selects preferred method - * Step 5: Completes payment using chosen method - * Step 6: Returns to your app with result - * - * โœ… ADVANTAGES: - * - Single integration supports 50+ payment methods - * - Users can choose their preferred payment method - * - No need to integrate multiple payment gateways - * - PhonePe handles all payment processing - * - Automatic fallback if one method fails - * - * ๐Ÿ”’ SECURITY: - * - All payments are secured by PhonePe - * - PCI DSS compliant - * - Bank-grade encryption - * - No sensitive data stored in your app - */ - -object PaymentMethodsGuide { - - val supportedUpiApps = listOf( - "Google Pay", "Paytm", "PhonePe", "BHIM", "Amazon Pay", - "WhatsApp Pay", "Bank UPI Apps" - ) - - val supportedCards = listOf( - "Visa", "Mastercard", "RuPay", "American Express", "Diners Club" - ) - - val supportedBanks = listOf( - "SBI", "HDFC", "ICICI", "Axis", "Kotak", "PNB", "BOB", "100+ others" - ) - - val supportedWallets = listOf( - "PhonePe Wallet", "Paytm Wallet", "Amazon Pay", "Mobikwik", "Freecharge" - ) - - val supportedBNPL = listOf( - "Simpl", "LazyPay", "ICICI PayLater", "Flipkart Pay Later" - ) - - fun getAllPaymentMethods(): Map> { - return mapOf( - "UPI" to supportedUpiApps, - "Cards" to supportedCards, - "Net Banking" to supportedBanks, - "Wallets" to supportedWallets, - "BNPL" to supportedBNPL - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/talkeys_new/utils/PhonePeConfig.kt b/app/src/main/java/com/example/talkeys_new/utils/PhonePeConfig.kt index 91619d6..0cc03ad 100644 --- a/app/src/main/java/com/example/talkeys_new/utils/PhonePeConfig.kt +++ b/app/src/main/java/com/example/talkeys_new/utils/PhonePeConfig.kt @@ -1,24 +1,21 @@ package com.example.talkeys_new.utils +import com.talkeys.shared.config.ProductionConfig + /** - * PhonePe Configuration - * - * SECURITY WARNING: - * - These are your REAL PhonePe credentials - * - Keep this file secure and never commit to public repositories - * - Consider using BuildConfig or encrypted storage for production + * PhonePe mobile configuration. + * + * Only public SDK identifiers belong here. Order creation, signing, and + * verification must stay on the backend. */ object PhonePeConfig { - // PhonePe Business Dashboard Credentials - // SECURITY: CLIENT_SECRET must never be stored in the mobile binary. - // It belongs on the backend server only. The value previously committed here - // has been removed and MUST be rotated โ€” see CURRENT_CLIENT_API_AUDIT.md. + // Public identifiers used by the mobile SDK. const val MERCHANT_ID = "M22ZDT307F584" - const val CLIENT_ID = "SU2504181253408025787154" + const val CLIENT_ID = ProductionConfig.PHONEPE_CLIENT_ID // Environment Configuration - const val IS_PRODUCTION = true // โš ๏ธ PRODUCTION MODE - REAL MONEY WILL BE CHARGED! + const val IS_PRODUCTION = ProductionConfig.IS_PHONEPE_PRODUCTION // API Endpoints (you'll need these for backend integration) const val SANDBOX_BASE_URL = "https://api-preprod.phonepe.com/apis/pg-sandbox" @@ -40,7 +37,6 @@ object PhonePeConfig { } /** - * CLIENT_ID is used for SDK initialization (safe to embed in the app). - * CLIENT_SECRET belongs on the backend only โ€” never in a mobile binary. + * CLIENT_ID is used for SDK initialization and is safe to embed in the app. */ -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/talkeys_new/utils/PhonePeDashboardMatch.kt b/app/src/main/java/com/example/talkeys_new/utils/PhonePeDashboardMatch.kt deleted file mode 100644 index f4bce63..0000000 --- a/app/src/main/java/com/example/talkeys_new/utils/PhonePeDashboardMatch.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.talkeys_new.utils - -// SECURITY: Production CLIENT_SECRET was previously stored here and has been removed. -// The exposed secret MUST be rotated in the PhonePe dashboard โ€” see CURRENT_CLIENT_API_AUDIT.md. -// CLIENT_SECRET belongs on the backend server only, never in a mobile binary. - -object PhonePeDashboardMatch { - const val CLIENT_ID = PhonePeConfig.CLIENT_ID -} \ No newline at end of file diff --git a/app/src/main/java/com/example/talkeys_new/utils/SecurityReminder.kt b/app/src/main/java/com/example/talkeys_new/utils/SecurityReminder.kt deleted file mode 100644 index 873880e..0000000 --- a/app/src/main/java/com/example/talkeys_new/utils/SecurityReminder.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.example.talkeys_new.utils - -/** - * PhonePe signing secrets belong only on the backend. - * - * A production client secret was previously embedded in app source. It has - * been removed from the client, but must be rotated outside this repository. - */ - -object SecurityReminder { - const val REMINDER = "Keep PhonePe signing secrets on the backend only." -} diff --git a/app/src/main/java/com/example/talkeys_new/utils/TestingGuide.kt b/app/src/main/java/com/example/talkeys_new/utils/TestingGuide.kt deleted file mode 100644 index c10e6f2..0000000 --- a/app/src/main/java/com/example/talkeys_new/utils/TestingGuide.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.example.talkeys_new.utils - -/** - * PhonePe Integration Testing Guide - * - * Since there's no backend implementation yet, here's what you can test: - * - * โœ… WHAT WORKS (Frontend Only): - * - * 1. FREE EVENT FLOW: - * - Find an event with price = 0 or null - * - Click "Register Now" - * - Should show "Free Event Registration" - * - Click "Register for Free" - * - Should navigate to success screen - * - * 2. UI NAVIGATION: - * - Event Detail โ†’ Payment Screen โœ… - * - Payment Screen โ†’ Success Screen โœ… - * - Back navigation โœ… - * - * 3. PAYMENT SCREEN UI: - * - Event details display correctly โœ… - * - PhonePe payment option shows โœ… - * - Loading states work โœ… - * - * โŒ WHAT WON'T WORK (Missing Backend): - * - * 1. PAID EVENT FLOW: - * - PhonePe payment will fail with dummy token - * - No real money transaction - * - No order creation/verification - * - * ๐Ÿงช TESTING STEPS: - * - * Step 1: Test Free Events - * - Look for events with price 0 - * - Complete registration flow - * - Verify success screen appears - * - * Step 2: Test Paid Event UI - * - Look for events with price > 0 - * - Navigate to payment screen - * - Verify PhonePe UI appears - * - Click payment button (will fail, but UI should work) - * - * Step 3: Test Navigation - * - Test back buttons - * - Test screen transitions - * - Verify no crashes - * - * ๐Ÿ“ EXPECTED BEHAVIOR: - * - * Free Events: Complete flow works โœ… - * Paid Events: UI works, payment fails โŒ - * Navigation: All transitions work โœ… - * Error Handling: Shows appropriate messages โœ… - */ - -object TestingGuide { - - const val FREE_EVENT_EXPECTED = "Should complete registration successfully" - const val PAID_EVENT_EXPECTED = "Payment will fail but UI should work" - const val NAVIGATION_EXPECTED = "All screen transitions should work" - - fun getTestingInstructions(): List { - return listOf( - "1. Find a free event (price = 0) and test complete registration flow", - "2. Find a paid event and test payment screen UI (payment will fail)", - "3. Test all navigation flows and back buttons", - "4. Verify no app crashes occur during testing", - "5. Check that appropriate error messages are shown" - ) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/config/ProductionConfig.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/config/ProductionConfig.kt index 36fa082..5f7b1e1 100644 --- a/shared/src/commonMain/kotlin/com/talkeys/shared/config/ProductionConfig.kt +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/config/ProductionConfig.kt @@ -8,10 +8,10 @@ object ProductionConfig { // Environment Settings const val IS_PRODUCTION = true // Production API mode - const val IS_DEBUG_LOGGING_ENABLED = true // Keep debug logging for PhonePe testing + const val IS_DEBUG_LOGGING_ENABLED = false // PhonePe-specific environment (separate from API environment) - const val IS_PHONEPE_PRODUCTION = false // PhonePe in TEST mode while API stays production + const val IS_PHONEPE_PRODUCTION = true // API Configuration - Production Backend const val API_BASE_URL = "https://api.talkeys.xyz" @@ -19,8 +19,8 @@ object ProductionConfig { const val API_RETRY_COUNT = 3 // PhonePe Configuration - const val PHONEPE_ENVIRONMENT = "SANDBOX" // "SANDBOX" or "PRODUCTION" - switched to test - const val PHONEPE_CLIENT_ID = "TEST-M22ZDT307F584_25062" + const val PHONEPE_ENVIRONMENT = "PRODUCTION" + const val PHONEPE_CLIENT_ID = "SU2504181253408025787154" // Payment Configuration const val DEFAULT_CURRENCY = "INR" @@ -66,4 +66,4 @@ object ProductionConfig { else -> false } } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentRepository.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentRepository.kt index ca0f98c..88f2931 100644 --- a/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentRepository.kt +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentRepository.kt @@ -4,7 +4,7 @@ import com.talkeys.shared.network.PaymentApiService import com.talkeys.shared.config.ProductionConfig import co.touchlab.kermit.Logger -class PaymentRepository(private val paymentApiService: PaymentApiService) { +open class PaymentRepository(private val paymentApiService: PaymentApiService) { private val logger = Logger.withTag("PaymentRepository") @@ -18,7 +18,7 @@ class PaymentRepository(private val paymentApiService: PaymentApiService) { /** * Book ticket and get payment order details */ - suspend fun bookTicket( + open suspend fun bookTicket( eventId: String, passType: String, friends: List, @@ -65,7 +65,7 @@ class PaymentRepository(private val paymentApiService: PaymentApiService) { /** * Verify payment status after PhonePe payment completion */ - suspend fun verifyPaymentStatus(merchantOrderId: String, authToken: String? = null): Result { + open suspend fun verifyPaymentStatus(merchantOrderId: String, authToken: String? = null): Result { logger.d { "Verifying payment status" } return try { diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCoordinator.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCoordinator.kt new file mode 100644 index 0000000..d306086 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCoordinator.kt @@ -0,0 +1,185 @@ +package com.talkeys.shared.presentation.events + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.talkeys.shared.data.events.EventSummary +import com.talkeys.shared.data.events.EventsRepository +import com.talkeys.shared.network.ApiError +import com.talkeys.shared.network.ApiResult +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +enum class EventsRoute { + EventList, + EventCreation +} + +sealed interface EventPlatformRequest { + data class ShareEvent( + val eventId: String, + val subject: String, + val text: String + ) : EventPlatformRequest + + data class NavigateToEventDetail(val eventId: String) : EventPlatformRequest + data class NavigateToRoute(val route: EventsRoute) : EventPlatformRequest +} + +data class EventCoordinatorUiState( + val isLoading: Boolean = false, + val allEvents: List = emptyList(), + val filteredEvents: List = emptyList(), + val selectedEvent: EventSummary? = null, + val showLiveEvents: Boolean = true, + val searchQuery: String = "", + val selectedCategory: String? = null, + val likedEventIds: Set = emptySet(), + val registrationIntentEventIds: Set = emptySet(), + val error: String? = null +) + +class EventCoordinator( + private val repository: EventsRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(EventCoordinatorUiState(isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _platformRequests = MutableSharedFlow(extraBufferCapacity = 1) + val platformRequests: SharedFlow = _platformRequests.asSharedFlow() + + init { + loadEvents() + } + + fun loadEvents(forceRefresh: Boolean = false) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + when (val result = repository.getAllEvents(forceRefresh)) { + is ApiResult.Success -> { + _uiState.value = _uiState.value + .copy(isLoading = false, allEvents = result.data, error = null) + .withAppliedFilters() + } + is ApiResult.Failure -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = errorMessage(result.error) + ) + } + } + } + } + + fun refresh() { + loadEvents(forceRefresh = true) + } + + fun selectEvent(eventId: String) { + val selected = _uiState.value.allEvents.firstOrNull { it.id == eventId } + _uiState.value = _uiState.value.copy(selectedEvent = selected) + } + + fun toggleLiveFilter() { + _uiState.value = _uiState.value + .copy(showLiveEvents = !_uiState.value.showLiveEvents) + .withAppliedFilters() + } + + fun updateSearchQuery(query: String) { + _uiState.value = _uiState.value + .copy(searchQuery = query) + .withAppliedFilters() + } + + fun updateCategory(category: String?) { + _uiState.value = _uiState.value + .copy(selectedCategory = category?.takeIf { it.isNotBlank() }) + .withAppliedFilters() + } + + fun clearFilters() { + _uiState.value = _uiState.value + .copy(showLiveEvents = true, searchQuery = "", selectedCategory = null) + .withAppliedFilters() + } + + fun toggleLocalLike(eventId: String): Boolean { + val current = _uiState.value.likedEventIds + val next = if (eventId in current) current - eventId else current + eventId + _uiState.value = _uiState.value.copy(likedEventIds = next) + return eventId in next + } + + fun requestRegistration(eventId: String) { + _uiState.value = _uiState.value.copy( + registrationIntentEventIds = _uiState.value.registrationIntentEventIds + eventId + ) + } + + fun requestShare(eventId: String) { + val event = _uiState.value.allEvents.firstOrNull { it.id == eventId } ?: return + _platformRequests.tryEmit( + EventPlatformRequest.ShareEvent( + eventId = event.id, + subject = "Check out this event: ${event.name}", + text = event.shareText() + ) + ) + } + + fun requestNavigateToEventDetail(eventId: String) { + _platformRequests.tryEmit(EventPlatformRequest.NavigateToEventDetail(eventId)) + } + + fun requestNavigateToCreation() { + _platformRequests.tryEmit(EventPlatformRequest.NavigateToRoute(EventsRoute.EventCreation)) + } + + fun requestNavigateToList() { + _platformRequests.tryEmit(EventPlatformRequest.NavigateToRoute(EventsRoute.EventList)) + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + private fun EventCoordinatorUiState.withAppliedFilters(): EventCoordinatorUiState { + val filtered = allEvents + .filter { if (showLiveEvents) it.isLive else !it.isLive } + .filter { event -> + searchQuery.isBlank() || + event.name.contains(searchQuery, ignoreCase = true) || + event.eventDescription?.contains(searchQuery, ignoreCase = true) == true || + event.category.contains(searchQuery, ignoreCase = true) + } + .filter { event -> selectedCategory == null || event.category == selectedCategory } + + return copy(filteredEvents = filtered) + } + + private fun EventSummary.shareText(): String = buildString { + append("I found this interesting event: ") + append(name) + append("\n\n") + append(eventDescription ?: "No description available") + append("\n\nDate: ") + append(startDate) + append("\nTime: ") + append(startTime) + append("\nLocation: ") + append(location ?: "Online") + } + + private fun errorMessage(error: ApiError): String = when (error) { + is ApiError.NetworkError -> "Please check your internet connection and try again." + is ApiError.HttpError -> "Server error (${error.status}). Please try again." + is ApiError.ParseError -> "Could not read event data. Please try again." + is ApiError.Unknown -> error.cause + } +} diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCreationModels.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCreationModels.kt new file mode 100644 index 0000000..d2d991c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCreationModels.kt @@ -0,0 +1,106 @@ +package com.talkeys.shared.presentation.events + +data class SharedImage( + val bytes: ByteArray, + val mimeType: String, + val fileName: String +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SharedImage) return false + return bytes.contentEquals(other.bytes) && + mimeType == other.mimeType && + fileName == other.fileName + } + + override fun hashCode(): Int { + var result = bytes.contentHashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + fileName.hashCode() + return result + } +} + +data class OrganizerInfoDraft( + val organizerName: String = "", + val emailAddress: String = "", + val contactNumber: String = "", + val organizationName: String = "", + val cityState: String = "", + val socialMediaLinks: String = "", + val organizerDocument: SharedImage? = null +) + +data class EventDetailsDraft( + val eventName: String = "", + val eventType: String = "", + val eventCategory: String = "", + val eventDescription: String = "", + val eventBanner: SharedImage? = null +) + +data class EventScheduleDraft( + val eventDates: String = "", + val startTime: String = "", + val endTime: String = "", + val registrationDeadline: String = "", + val maxAttendees: String = "", + val platformUsed: String = "", + val willBeRecorded: String = "" +) + +data class EventPricingDraft( + val eventType: String = "", + val ticketPrice: String = "", + val discounts: String = "", + val discountPercentage: String = "", + val qrCheckIn: String = "", + val refundPolicy: String = "" +) + +data class EventAudienceDraft( + val communityChat: String = "", + val sponsors: String = "", + val audienceType: String = "", + val sponsorDeck: SharedImage? = null +) + +data class EventReviewDraft( + val isInfoAccurate: Boolean = false, + val agreeToTerms: Boolean = false +) + +data class EventCreationDraft( + val organizerInfo: OrganizerInfoDraft = OrganizerInfoDraft(), + val details: EventDetailsDraft = EventDetailsDraft(), + val schedule: EventScheduleDraft = EventScheduleDraft(), + val pricing: EventPricingDraft = EventPricingDraft(), + val audience: EventAudienceDraft = EventAudienceDraft(), + val review: EventReviewDraft = EventReviewDraft() +) + +enum class EventCreationStep(val number: Int) { + OrganizerInfo(1), + Details(2), + Schedule(3), + Pricing(4), + Audience(5), + Review(6); + + fun next(): EventCreationStep = entries.firstOrNull { it.number == number + 1 } ?: this + fun previous(): EventCreationStep = entries.firstOrNull { it.number == number - 1 } ?: this +} + +data class ValidationResult( + val isValid: Boolean, + val errors: Map = emptyMap() +) + +data class EventCreationUiState( + val draft: EventCreationDraft = EventCreationDraft(), + val currentStep: EventCreationStep = EventCreationStep.OrganizerInfo, + val validationErrors: Map = emptyMap(), + val isSubmitting: Boolean = false, + val submissionError: String? = null, + val submissionRequested: Boolean = false +) diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCreationValidator.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCreationValidator.kt new file mode 100644 index 0000000..0f87686 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCreationValidator.kt @@ -0,0 +1,118 @@ +package com.talkeys.shared.presentation.events + +object EventCreationValidator { + private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") + private val urlRegex = Regex("^(https?://)?([A-Za-z0-9-]+\\.)+[A-Za-z]{2,}(/.*)?$") + + fun validateOrganizerInfo(draft: OrganizerInfoDraft): ValidationResult = buildValidation { + requireField("organizerName", draft.organizerName, "Organizer name is required") + requireField("emailAddress", draft.emailAddress, "Email is required") + if (draft.emailAddress.isNotBlank() && !emailRegex.matches(draft.emailAddress.trim())) { + put("emailAddress", "Enter a valid email address") + } + requireField("contactNumber", draft.contactNumber, "Contact number is required") + if (draft.contactNumber.isNotBlank() && !isValidPhoneNumber(draft.contactNumber)) { + put("contactNumber", "Enter a valid contact number") + } + requireField("organizationName", draft.organizationName, "Organization name is required") + requireField("cityState", draft.cityState, "City and state are required") + requireField("socialMediaLinks", draft.socialMediaLinks, "Social media link is required") + if (draft.socialMediaLinks.isNotBlank() && !urlRegex.matches(draft.socialMediaLinks.trim())) { + put("socialMediaLinks", "Enter a valid social link") + } + if (draft.organizerDocument == null) { + put("organizerDocument", "Upload organizer verification document") + } + } + + fun validateDetails(draft: EventDetailsDraft): ValidationResult = buildValidation { + requireField("eventName", draft.eventName, "Event name is required") + if (draft.eventName.trim().length in 1..2) { + put("eventName", "Event name must be at least 3 characters") + } + requireField("eventType", draft.eventType, "Event type is required") + requireField("eventCategory", draft.eventCategory, "Event category is required") + requireField("eventDescription", draft.eventDescription, "Event description is required") + if (draft.eventDescription.trim().length in 1..19) { + put("eventDescription", "Description must be at least 20 characters") + } + } + + fun validateSchedule(draft: EventScheduleDraft): ValidationResult = buildValidation { + requireField("eventDates", draft.eventDates, "Event date is required") + requireField("startTime", draft.startTime, "Start time is required") + requireField("endTime", draft.endTime, "End time is required") + requireField("registrationDeadline", draft.registrationDeadline, "Registration deadline is required") + requireField("maxAttendees", draft.maxAttendees, "Maximum attendees is required") + if (draft.maxAttendees.isNotBlank() && draft.maxAttendees.toIntOrNull()?.let { it > 0 } != true) { + put("maxAttendees", "Maximum attendees must be a positive number") + } + requireField("platformUsed", draft.platformUsed, "Platform or venue details are required") + requireField("willBeRecorded", draft.willBeRecorded, "Recording preference is required") + } + + fun validatePricing(draft: EventPricingDraft): ValidationResult = buildValidation { + requireField("eventType", draft.eventType, "Choose free or paid") + if (draft.eventType.equals("Paid", ignoreCase = true)) { + requireField("ticketPrice", draft.ticketPrice, "Ticket price is required") + if (draft.ticketPrice.isNotBlank() && draft.ticketPrice.toDoubleOrNull()?.let { it >= 0.0 } != true) { + put("ticketPrice", "Ticket price must be a valid amount") + } + } + requireField("discounts", draft.discounts, "Choose whether discounts apply") + if (draft.discounts.equals("Yes", ignoreCase = true)) { + requireField("discountPercentage", draft.discountPercentage, "Discount percentage is required") + val discount = draft.discountPercentage.toIntOrNull() + if (draft.discountPercentage.isNotBlank() && (discount == null || discount !in 1..100)) { + put("discountPercentage", "Discount must be between 1 and 100") + } + } + requireField("qrCheckIn", draft.qrCheckIn, "Choose QR check-in preference") + requireField("refundPolicy", draft.refundPolicy, "Choose refund policy") + } + + fun validateAudience(draft: EventAudienceDraft): ValidationResult = buildValidation { + requireField("communityChat", draft.communityChat, "Choose community chat preference") + requireField("sponsors", draft.sponsors, "Choose sponsor preference") + requireField("audienceType", draft.audienceType, "Audience type is required") + } + + fun validateReview(draft: EventReviewDraft): ValidationResult = buildValidation { + if (!draft.isInfoAccurate) put("isInfoAccurate", "Confirm the event information is accurate") + if (!draft.agreeToTerms) put("agreeToTerms", "Accept the terms and privacy policy") + } + + fun validateStep(step: EventCreationStep, draft: EventCreationDraft): ValidationResult = when (step) { + EventCreationStep.OrganizerInfo -> validateOrganizerInfo(draft.organizerInfo) + EventCreationStep.Details -> validateDetails(draft.details) + EventCreationStep.Schedule -> validateSchedule(draft.schedule) + EventCreationStep.Pricing -> validatePricing(draft.pricing) + EventCreationStep.Audience -> validateAudience(draft.audience) + EventCreationStep.Review -> validateReview(draft.review) + } + + fun validateAll(draft: EventCreationDraft): ValidationResult { + val errors = EventCreationStep.entries + .flatMap { validateStep(it, draft).errors.entries } + .associate { it.key to it.value } + return ValidationResult(errors.isEmpty(), errors) + } + + private fun isValidPhoneNumber(phone: String): Boolean { + val digits = phone.filter { it.isDigit() } + return digits.length in 10..15 + } + + private inline fun buildValidation(block: MutableMap.() -> Unit): ValidationResult { + val errors = mutableMapOf().apply(block) + return ValidationResult(errors.isEmpty(), errors) + } + + private fun MutableMap.requireField( + key: String, + value: String, + message: String + ) { + if (value.isBlank()) put(key, message) + } +} diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCreationViewModel.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCreationViewModel.kt new file mode 100644 index 0000000..b74dc87 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventCreationViewModel.kt @@ -0,0 +1,92 @@ +package com.talkeys.shared.presentation.events + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class EventCreationViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(EventCreationUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateOrganizerInfo(value: OrganizerInfoDraft) { + updateDraft { copy(organizerInfo = value) } + } + + fun updateDetails(value: EventDetailsDraft) { + updateDraft { copy(details = value) } + } + + fun updateSchedule(value: EventScheduleDraft) { + updateDraft { copy(schedule = value) } + } + + fun updatePricing(value: EventPricingDraft) { + updateDraft { copy(pricing = value) } + } + + fun updateAudience(value: EventAudienceDraft) { + updateDraft { copy(audience = value) } + } + + fun updateReview(value: EventReviewDraft) { + updateDraft { copy(review = value) } + } + + fun validateCurrentStep(): Boolean { + val state = _uiState.value + val result = EventCreationValidator.validateStep(state.currentStep, state.draft) + _uiState.value = state.copy(validationErrors = result.errors) + return result.isValid + } + + fun moveToStep(step: EventCreationStep) { + _uiState.value = _uiState.value.copy( + currentStep = step, + validationErrors = emptyMap() + ) + } + + fun goNext(): Boolean { + if (!validateCurrentStep()) return false + _uiState.value = _uiState.value.copy( + currentStep = _uiState.value.currentStep.next(), + validationErrors = emptyMap() + ) + return true + } + + fun goPrevious() { + _uiState.value = _uiState.value.copy( + currentStep = _uiState.value.currentStep.previous(), + validationErrors = emptyMap() + ) + } + + fun requestSubmit(): Boolean { + val result = EventCreationValidator.validateAll(_uiState.value.draft) + _uiState.value = _uiState.value.copy( + validationErrors = result.errors, + submissionRequested = result.isValid, + submissionError = if (result.isValid) null else "Fix validation errors before submitting." + ) + return result.isValid + } + + fun markSubmissionHandled() { + _uiState.value = _uiState.value.copy(submissionRequested = false) + } + + fun resetDraft() { + _uiState.value = EventCreationUiState() + } + + private fun updateDraft(update: EventCreationDraft.() -> EventCreationDraft) { + _uiState.value = _uiState.value.copy( + draft = _uiState.value.draft.update(), + validationErrors = emptyMap(), + submissionError = null + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventsViewModelFactories.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventsViewModelFactories.kt index 52273cb..5188c48 100644 --- a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventsViewModelFactories.kt +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/events/EventsViewModelFactories.kt @@ -40,6 +40,28 @@ class EventDetailViewModelFactory( } } +class EventCoordinatorFactory( + private val repository: EventsRepository +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: KClass, extras: CreationExtras): T { + require(modelClass == EventCoordinator::class) { + "EventCoordinatorFactory cannot create ${modelClass.simpleName}" + } + return EventCoordinator(repository) as T + } +} + +class EventCreationViewModelFactory : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: KClass, extras: CreationExtras): T { + require(modelClass == EventCreationViewModel::class) { + "EventCreationViewModelFactory cannot create ${modelClass.simpleName}" + } + return EventCreationViewModel() as T + } +} + /** * Pre-wired factory accessors that pull [EventsRepository] from the Koin * graph. Callable from Swift as: @@ -54,3 +76,9 @@ val eventsListViewModelFactory: ViewModelProvider.Factory val eventDetailViewModelFactory: ViewModelProvider.Factory get() = EventDetailViewModelFactory(KoinHelper.get()) + +val eventCoordinatorFactory: ViewModelProvider.Factory + get() = EventCoordinatorFactory(KoinHelper.get()) + +val eventCreationViewModelFactory: ViewModelProvider.Factory = + EventCreationViewModelFactory() diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutModels.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutModels.kt new file mode 100644 index 0000000..b9a9ed0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutModels.kt @@ -0,0 +1,21 @@ +package com.talkeys.shared.presentation.payment + +data class PaymentCheckoutData( + val paymentUrl: String, + val merchantOrderId: String, + val passId: String +) + +data class PaymentCheckoutUiState( + val isLoading: Boolean = false, + val checkoutData: PaymentCheckoutData? = null, + val errorMessage: String? = null +) + +data class PaymentVerificationUiState( + val isLoading: Boolean = false, + val passId: String? = null, + val passUUID: String? = null, + val status: String? = null, + val errorMessage: String? = null +) diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModel.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModel.kt new file mode 100644 index 0000000..7232500 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModel.kt @@ -0,0 +1,89 @@ +package com.talkeys.shared.presentation.payment + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.talkeys.shared.config.ProductionConfig +import com.talkeys.shared.data.payment.Friend +import com.talkeys.shared.data.payment.PaymentRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class PaymentCheckoutViewModel( + private val repository: PaymentRepository, + private val isPhonePeProduction: Boolean = ProductionConfig.IS_PHONEPE_PRODUCTION +) : ViewModel() { + + private val _checkoutState = MutableStateFlow(PaymentCheckoutUiState()) + val checkoutState: StateFlow = _checkoutState.asStateFlow() + + private val _verificationState = MutableStateFlow(PaymentVerificationUiState()) + val verificationState: StateFlow = _verificationState.asStateFlow() + + fun startCheckout( + eventId: String, + passType: String, + friends: List, + authToken: String? + ) { + if (authToken.isNullOrBlank()) { + _checkoutState.value = PaymentCheckoutUiState(errorMessage = "Please login to continue") + return + } + + _checkoutState.value = PaymentCheckoutUiState(isLoading = true) + viewModelScope.launch { + repository.bookTicket(eventId, passType, friends, authToken) + .onSuccess { paymentOrder -> + _checkoutState.value = PaymentCheckoutUiState( + checkoutData = PaymentCheckoutData( + paymentUrl = PhonePeCheckoutUrlBuilder.buildCheckoutUrl( + tokenOrUrl = paymentOrder.token, + isProduction = isPhonePeProduction + ), + merchantOrderId = paymentOrder.merchantOrderId, + passId = paymentOrder.passId + ) + ) + } + .onFailure { error -> + _checkoutState.value = PaymentCheckoutUiState( + errorMessage = error.message ?: "Failed to create payment" + ) + } + } + } + + fun verifyPaymentStatus(merchantOrderId: String, authToken: String?) { + if (authToken.isNullOrBlank()) { + _verificationState.value = PaymentVerificationUiState(errorMessage = "Please login to verify payment") + return + } + + _verificationState.value = PaymentVerificationUiState(isLoading = true) + viewModelScope.launch { + repository.verifyPaymentStatus(merchantOrderId, authToken) + .onSuccess { status -> + _verificationState.value = PaymentVerificationUiState( + passId = status.passId, + passUUID = status.passUUID, + status = status.paymentStatus + ) + } + .onFailure { error -> + _verificationState.value = PaymentVerificationUiState( + errorMessage = error.message ?: "Failed to verify payment" + ) + } + } + } + + fun clearCheckout() { + _checkoutState.value = PaymentCheckoutUiState() + } + + fun clearVerification() { + _verificationState.value = PaymentVerificationUiState() + } +} diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentViewModelFactories.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentViewModelFactories.kt new file mode 100644 index 0000000..6342046 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentViewModelFactories.kt @@ -0,0 +1,26 @@ +package com.talkeys.shared.presentation.payment + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import com.talkeys.shared.data.payment.PaymentRepository +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import kotlin.reflect.KClass + +class PaymentCheckoutViewModelFactory( + private val repository: PaymentRepository +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: KClass, extras: CreationExtras): T { + require(modelClass == PaymentCheckoutViewModel::class) { + "PaymentCheckoutViewModelFactory cannot create ${modelClass.simpleName}" + } + return PaymentCheckoutViewModel(repository) as T + } +} + +private object KoinHelper : KoinComponent + +val paymentCheckoutViewModelFactory: ViewModelProvider.Factory + get() = PaymentCheckoutViewModelFactory(KoinHelper.get()) diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PhonePeCheckoutUrlBuilder.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PhonePeCheckoutUrlBuilder.kt new file mode 100644 index 0000000..d947199 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PhonePeCheckoutUrlBuilder.kt @@ -0,0 +1,35 @@ +package com.talkeys.shared.presentation.payment + +object PhonePeCheckoutUrlBuilder { + private const val PRODUCTION_CHECKOUT_BASE_URL = "https://mercury.phonepe.com/transact/pg" + private const val SANDBOX_CHECKOUT_BASE_URL = "https://mercury-t2.phonepe.com/transact/pg" + + fun buildCheckoutUrl(tokenOrUrl: String, isProduction: Boolean): String { + val trimmed = tokenOrUrl.trim() + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed + + val baseUrl = if (isProduction) PRODUCTION_CHECKOUT_BASE_URL else SANDBOX_CHECKOUT_BASE_URL + return "$baseUrl?token=${urlEncode(trimmed)}" + } + + private fun urlEncode(value: String): String = buildString { + value.encodeToByteArray().forEach { byte -> + val char = byte.toInt().toChar() + if (char.isUrlSafe()) { + append(char) + } else { + append('%') + append(byte.toUByte().toString(16).uppercase().padStart(2, '0')) + } + } + } + + private fun Char.isUrlSafe(): Boolean = + this in 'A'..'Z' || + this in 'a'..'z' || + this in '0'..'9' || + this == '-' || + this == '_' || + this == '.' || + this == '~' +} diff --git a/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/events/EventCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/events/EventCoordinatorTest.kt new file mode 100644 index 0000000..7d1ff79 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/events/EventCoordinatorTest.kt @@ -0,0 +1,113 @@ +package com.talkeys.shared.presentation.events + +import com.talkeys.shared.data.events.EventSummary +import com.talkeys.shared.data.events.EventsApi +import com.talkeys.shared.data.events.EventsRepository +import com.talkeys.shared.network.ApiClient +import com.talkeys.shared.network.ApiError +import com.talkeys.shared.network.ApiResult +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class EventCoordinatorTest { + + private val events = listOf( + event(id = "1", name = "Kotlin Meetup", category = "Tech", isLive = true), + event(id = "2", name = "Design Jam", category = "Design", isLive = true), + event(id = "3", name = "Past Talk", category = "Tech", isLive = false) + ) + + @Test + fun loadEvents_appliesLiveFilterByDefault() = runTest { + val coordinator = EventCoordinator(repositoryReturning(ApiResult.Success(events))) + advanceUntilIdle() + + val state = coordinator.uiState.value + assertEquals(3, state.allEvents.size) + assertEquals(listOf("1", "2"), state.filteredEvents.map { it.id }) + assertEquals(true, state.showLiveEvents) + } + + @Test + fun searchAndCategoryFiltersAreAppliedTogether() = runTest { + val coordinator = EventCoordinator(repositoryReturning(ApiResult.Success(events))) + advanceUntilIdle() + + coordinator.updateSearchQuery("kotlin") + coordinator.updateCategory("Tech") + + assertEquals(listOf("1"), coordinator.uiState.value.filteredEvents.map { it.id }) + } + + @Test + fun toggleLocalLike_updatesLikedIds() = runTest { + val coordinator = EventCoordinator(repositoryReturning(ApiResult.Success(events))) + advanceUntilIdle() + + assertTrue(coordinator.toggleLocalLike("1")) + assertEquals(setOf("1"), coordinator.uiState.value.likedEventIds) + + assertEquals(false, coordinator.toggleLocalLike("1")) + assertEquals(emptySet(), coordinator.uiState.value.likedEventIds) + } + + @Test + fun requestShare_emitsPlatformRequest() = runTest { + val coordinator = EventCoordinator(repositoryReturning(ApiResult.Success(events))) + advanceUntilIdle() + + val request = async { coordinator.platformRequests.first() } + runCurrent() + coordinator.requestShare("1") + val emitted = request.await() + + assertIs(emitted) + assertEquals("1", emitted.eventId) + assertTrue(emitted.text.contains("Kotlin Meetup")) + } + + @Test + fun loadEvents_failure_setsError() = runTest { + val coordinator = EventCoordinator(repositoryReturning(ApiResult.Failure(ApiError.NetworkError))) + advanceUntilIdle() + + assertEquals("Please check your internet connection and try again.", coordinator.uiState.value.error) + } + + private fun repositoryReturning(result: ApiResult>) = + object : EventsRepository(EventsApi(ApiClient())) { + override suspend fun getAllEvents(forceRefresh: Boolean): ApiResult> = result + } + + private fun event( + id: String, + name: String, + category: String, + isLive: Boolean + ) = EventSummary( + id = id, + name = name, + category = category, + ticketPrice = "0", + mode = "online", + duration = "1h", + slots = 10, + visibility = "public", + startDate = "2026-01-01", + startTime = "10:00", + totalSeats = 100, + isTeamEvent = false, + isPaid = false, + isLive = isLive, + eventDescription = "$name description" + ) +} diff --git a/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/events/EventCreationViewModelTest.kt b/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/events/EventCreationViewModelTest.kt new file mode 100644 index 0000000..c81ec6c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/events/EventCreationViewModelTest.kt @@ -0,0 +1,136 @@ +package com.talkeys.shared.presentation.events + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EventCreationViewModelTest { + + @Test + fun organizerInfoValidation_requiresCoreFieldsAndDocument() { + val result = EventCreationValidator.validateOrganizerInfo(OrganizerInfoDraft()) + + assertFalse(result.isValid) + assertTrue("organizerName" in result.errors) + assertTrue("emailAddress" in result.errors) + assertTrue("socialMediaLinks" in result.errors) + assertTrue("organizerDocument" in result.errors) + } + + @Test + fun pricingValidation_requiresPriceOnlyForPaidEvents() { + val freeResult = EventCreationValidator.validatePricing( + EventPricingDraft( + eventType = "Free", + discounts = "No", + qrCheckIn = "Yes", + refundPolicy = "No refunds" + ) + ) + assertTrue(freeResult.isValid) + + val paidResult = EventCreationValidator.validatePricing( + EventPricingDraft( + eventType = "Paid", + discounts = "No", + qrCheckIn = "Yes", + refundPolicy = "No refunds" + ) + ) + assertFalse(paidResult.isValid) + assertEquals("Ticket price is required", paidResult.errors["ticketPrice"]) + } + + @Test + fun goNext_staysOnCurrentStepWhenInvalid() { + val viewModel = EventCreationViewModel() + + val moved = viewModel.goNext() + + assertFalse(moved) + assertEquals(EventCreationStep.OrganizerInfo, viewModel.uiState.value.currentStep) + assertTrue(viewModel.uiState.value.validationErrors.isNotEmpty()) + } + + @Test + fun goNext_advancesWhenCurrentStepIsValid() { + val viewModel = EventCreationViewModel() + viewModel.updateOrganizerInfo(validOrganizerInfo()) + + val moved = viewModel.goNext() + + assertTrue(moved) + assertEquals(EventCreationStep.Details, viewModel.uiState.value.currentStep) + } + + @Test + fun moveToStep_setsCurrentStepAndClearsValidationErrors() { + val viewModel = EventCreationViewModel() + viewModel.goNext() + + viewModel.moveToStep(EventCreationStep.Pricing) + + assertEquals(EventCreationStep.Pricing, viewModel.uiState.value.currentStep) + assertTrue(viewModel.uiState.value.validationErrors.isEmpty()) + } + + @Test + fun requestSubmit_setsSubmissionRequestedOnlyForCompleteDraft() { + val viewModel = EventCreationViewModel() + viewModel.updateOrganizerInfo(validOrganizerInfo()) + viewModel.updateDetails( + EventDetailsDraft( + eventName = "Kotlin Meetup", + eventType = "Workshop", + eventCategory = "Tech", + eventDescription = "A long enough event description" + ) + ) + viewModel.updateSchedule( + EventScheduleDraft( + eventDates = "2026-01-01", + startTime = "10:00", + endTime = "12:00", + registrationDeadline = "2025-12-25", + maxAttendees = "100", + platformUsed = "Google Meet", + willBeRecorded = "No" + ) + ) + viewModel.updatePricing( + EventPricingDraft( + eventType = "Free", + discounts = "No", + qrCheckIn = "Yes", + refundPolicy = "No refunds" + ) + ) + viewModel.updateAudience( + EventAudienceDraft( + communityChat = "Yes", + sponsors = "No", + audienceType = "Students" + ) + ) + viewModel.updateReview(EventReviewDraft(isInfoAccurate = true, agreeToTerms = true)) + + assertTrue(viewModel.requestSubmit()) + assertTrue(viewModel.uiState.value.submissionRequested) + assertTrue(viewModel.uiState.value.validationErrors.isEmpty()) + } + + private fun validOrganizerInfo() = OrganizerInfoDraft( + organizerName = "Talkeys", + emailAddress = "team@talkeys.xyz", + contactNumber = "9876543210", + organizationName = "Talkeys", + cityState = "Delhi, India", + socialMediaLinks = "https://talkeys.xyz", + organizerDocument = SharedImage( + bytes = byteArrayOf(1, 2, 3), + mimeType = "image/png", + fileName = "doc.png" + ) + ) +} diff --git a/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModelTest.kt b/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModelTest.kt new file mode 100644 index 0000000..8068a61 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModelTest.kt @@ -0,0 +1,122 @@ +package com.talkeys.shared.presentation.payment + +import com.talkeys.shared.data.payment.EventInfo +import com.talkeys.shared.data.payment.PaymentOrderData +import com.talkeys.shared.data.payment.PaymentRepository +import com.talkeys.shared.data.payment.PaymentStatusData +import com.talkeys.shared.network.ApiClient +import com.talkeys.shared.network.PaymentApiService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PaymentCheckoutViewModelTest { + + @Test + fun urlBuilder_preservesFullCheckoutUrl() { + val url = "https://mercury-t2.phonepe.com/transact/pg?token=abc" + + assertEquals(url, PhonePeCheckoutUrlBuilder.buildCheckoutUrl(url, isProduction = false)) + } + + @Test + fun urlBuilder_usesSandboxBaseAndEncodesToken() { + val url = PhonePeCheckoutUrlBuilder.buildCheckoutUrl("abc+123/=", isProduction = false) + + assertEquals("https://mercury-t2.phonepe.com/transact/pg?token=abc%2B123%2F%3D", url) + } + + @Test + fun startCheckout_requiresAuthToken() = runTest { + val viewModel = PaymentCheckoutViewModel(fakeRepository(Result.success(orderData()))) + + viewModel.startCheckout("event-1", "General", emptyList(), authToken = null) + + assertFalse(viewModel.checkoutState.value.isLoading) + assertEquals("Please login to continue", viewModel.checkoutState.value.errorMessage) + } + + @Test + fun startCheckout_setsCheckoutDataFromRepositoryOrder() = runTest { + val viewModel = PaymentCheckoutViewModel( + repository = fakeRepository(Result.success(orderData(token = "token+abc"))), + isPhonePeProduction = false + ) + + viewModel.startCheckout("event-1", "General", emptyList(), authToken = "jwt") + advanceUntilIdle() + + val checkout = assertNotNull(viewModel.checkoutState.value.checkoutData) + assertEquals("merchant-1", checkout.merchantOrderId) + assertEquals("pass-1", checkout.passId) + assertEquals("https://mercury-t2.phonepe.com/transact/pg?token=token%2Babc", checkout.paymentUrl) + } + + @Test + fun verifyPaymentStatus_setsCompletedStatus() = runTest { + val viewModel = PaymentCheckoutViewModel( + repository = fakeRepository( + bookTicketResult = Result.success(orderData()), + statusResult = Result.success(PaymentStatusData("pass-1", "uuid-1", "COMPLETED")) + ) + ) + + viewModel.verifyPaymentStatus("merchant-1", "jwt") + advanceUntilIdle() + + assertEquals("COMPLETED", viewModel.verificationState.value.status) + assertEquals("pass-1", viewModel.verificationState.value.passId) + assertTrue(viewModel.verificationState.value.errorMessage == null) + } + + @Test + fun verifyPaymentStatus_requiresAuthToken() = runTest { + val viewModel = PaymentCheckoutViewModel( + repository = fakeRepository( + bookTicketResult = Result.success(orderData()), + statusResult = Result.success(PaymentStatusData("pass-1", "uuid-1", "COMPLETED")) + ) + ) + + viewModel.verifyPaymentStatus("merchant-1", authToken = null) + + assertFalse(viewModel.verificationState.value.isLoading) + assertEquals("Please login to verify payment", viewModel.verificationState.value.errorMessage) + } + + private fun fakeRepository( + bookTicketResult: Result, + statusResult: Result = Result.failure(Exception("not used")) + ): PaymentRepository = object : PaymentRepository(PaymentApiService(ApiClient())) { + override suspend fun bookTicket( + eventId: String, + passType: String, + friends: List, + authToken: String? + ): Result = bookTicketResult + + override suspend fun verifyPaymentStatus( + merchantOrderId: String, + authToken: String? + ): Result = statusResult + } + + private fun orderData(token: String = "token") = PaymentOrderData( + passId = "pass-1", + merchantOrderId = "merchant-1", + orderId = "order-1", + amount = 100, + amountInPaisa = 10000, + totalTickets = 1, + token = token, + event = EventInfo(id = "event-1"), + qrStrings = emptyList(), + friends = emptyList() + ) +}