From 677f084f12bfaa05e314e08207ccea464c834764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Wed, 11 Dec 2024 19:11:35 +0100 Subject: [PATCH 01/15] Refactor: extract filtering component from SearchWorkerResult.kt to SearchFilters.kt --- .../quickfix/ui/search/ProfileResults.kt | 98 +-- .../quickfix/ui/search/QuickFixFinder.kt | 178 +++-- .../arygm/quickfix/ui/search/SearchFilters.kt | 249 ++++++ .../quickfix/ui/search/SearchOnBoarding.kt | 300 +++---- .../quickfix/ui/search/SearchWorkerResult.kt | 752 +++++++----------- 5 files changed, 856 insertions(+), 721 deletions(-) create mode 100644 app/src/main/java/com/arygm/quickfix/ui/search/SearchFilters.kt diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/ProfileResults.kt b/app/src/main/java/com/arygm/quickfix/ui/search/ProfileResults.kt index ad1f7539..c251548c 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/search/ProfileResults.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/search/ProfileResults.kt @@ -35,57 +35,59 @@ fun ProfileResults( heightRatio: Float, onBookClick: (WorkerProfile) -> Unit ) { - // LazyColumn for displaying profiles - LazyColumn(modifier = modifier.fillMaxWidth(), state = listState) { - items(profiles.size) { index -> - val profile = profiles[index] - var account by remember { mutableStateOf(null) } - var distance by remember { mutableStateOf(null) } + // LazyColumn for displaying profiles + LazyColumn(modifier = modifier.fillMaxWidth(), state = listState) { + items(profiles.size) { index -> + val profile = profiles[index] + var account by remember { mutableStateOf(null) } + var distance by remember { mutableStateOf(null) } - // Get user's current location and calculate distance - val locationHelper = LocationHelper(LocalContext.current, MainActivity()) - locationHelper.getCurrentLocation { location -> - location?.let { - distance = - profile.location?.let { workerLocation -> - searchViewModel - .calculateDistance( - workerLocation.latitude, - workerLocation.longitude, - it.latitude, - it.longitude) - .toInt() - } - } - } + // Get user's current location and calculate distance + val locationHelper = LocationHelper(LocalContext.current, MainActivity()) + locationHelper.getCurrentLocation { location -> + location?.let { + distance = + profile.location?.let { workerLocation -> + searchViewModel + .calculateDistance( + workerLocation.latitude, + workerLocation.longitude, + it.latitude, + it.longitude + ) + .toInt() + } + } + } - // Fetch user account details - LaunchedEffect(profile.uid) { - accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> - account = fetchedAccount - } - } + // Fetch user account details + LaunchedEffect(profile.uid) { + accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> + account = fetchedAccount + } + } - // Render profile card if account data is available - account?.let { acc -> - SearchWorkerProfileResult( - modifier = - Modifier.padding(vertical = 10.dp * heightRatio) - .fillMaxWidth() - .testTag("worker_profile_result_$index") - .clickable {}, - profileImage = R.drawable.placeholder_worker, - name = "${acc.firstName} ${acc.lastName}", - category = profile.fieldOfWork, - rating = profile.rating, - reviewCount = profile.reviews.size, - location = profile.location?.name ?: "Unknown", - price = profile.price.toString(), - distance = distance, - onBookClick = { onBookClick(profile) }) - } + // Render profile card if account data is available + account?.let { acc -> + SearchWorkerProfileResult( + modifier = + Modifier + .padding(vertical = 0.dp) + .fillMaxWidth() + .testTag("worker_profile_result_$index") + .clickable {}, + profileImage = R.drawable.placeholder_worker, + name = "${acc.firstName} ${acc.lastName}", + category = profile.fieldOfWork, + rating = profile.rating, + reviewCount = profile.reviews.size, + location = profile.location?.name ?: "Unknown", + price = profile.price.toString(), + distance = distance, + onBookClick = { onBookClick(profile) }) + } - Spacer(modifier = Modifier.height(10.dp * heightRatio)) + Spacer(modifier = Modifier.height(0.dp)) + } } - } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/search/QuickFixFinder.kt index f8db1e3e..dbd4be04 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/search/QuickFixFinder.kt @@ -20,7 +20,11 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -53,71 +57,93 @@ fun QuickFixFinderScreen( viewModel(factory = AnnouncementViewModel.Factory), categoryViewModel: CategoryViewModel = viewModel(factory = CategoryViewModel.Factory) ) { - Scaffold( - containerColor = colorScheme.background, - topBar = { - TopAppBar( - title = { - Text( - text = "Quickfix", - color = colorScheme.primary, - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier.testTag("QuickFixFinderTopBarTitle")) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.background), - modifier = Modifier.testTag("QuickFixFinderTopBar")) - }, - content = { padding -> - Column( - modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally) { - val pagerState = rememberPagerState(pageCount = { 2 }) - val coroutineScope = rememberCoroutineScope() + var pager by remember { mutableStateOf(true) } + Scaffold( + containerColor = colorScheme.background, + topBar = { + TopAppBar( + title = { + Text( + text = "Quickfix", + color = colorScheme.primary, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.testTag("QuickFixFinderTopBarTitle") + ) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.background), + modifier = Modifier.testTag("QuickFixFinderTopBar") + ) + }, + content = { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .testTag("QuickFixFinderContent") + .padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + val pagerState = rememberPagerState(pageCount = { 2 }) + val coroutineScope = rememberCoroutineScope() - Surface( - color = colorScheme.surface, - shape = RoundedCornerShape(20.dp), - modifier = Modifier.padding(horizontal = 40.dp).clip(RoundedCornerShape(20.dp))) { - TabRow( - selectedTabIndex = pagerState.currentPage, - containerColor = Color.Transparent, - divider = {}, - indicator = {}, - modifier = - Modifier.padding(horizontal = 1.dp, vertical = 1.dp) + if (pager) { + Surface( + color = colorScheme.surface, + shape = RoundedCornerShape(20.dp), + modifier = Modifier + .padding(horizontal = 40.dp) + .clip(RoundedCornerShape(20.dp)) + ) { + TabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = Color.Transparent, + divider = {}, + indicator = {}, + modifier = + Modifier + .padding(horizontal = 1.dp, vertical = 1.dp) .align(Alignment.CenterHorizontally) - .testTag("quickFixSearchTabRow")) { - QuickFixScreenTab(pagerState, coroutineScope, 0, "Search") - QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce") + .testTag("quickFixSearchTabRow") + ) { + QuickFixScreenTab(pagerState, coroutineScope, 0, "Search") + QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce") } - } - - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - modifier = Modifier.testTag("quickFixSearchPager")) { page -> + } + } + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.testTag("quickFixSearchPager") + ) { page -> when (page) { - 0 -> - SearchOnBoarding( - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel) - 1 -> - AnnouncementScreen( - announcementViewModel, - loggedInAccountViewModel, - profileViewModel, - accountViewModel, - navigationActions, - isUser) - else -> Text("Should never happen !") + 0 -> { + SearchOnBoarding( + onSearch = { pager = false }, + onSearchEmpty = { pager = true }, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel + ) + } + + 1 -> { + AnnouncementScreen( + announcementViewModel, + loggedInAccountViewModel, + profileViewModel, + accountViewModel, + navigationActions, + isUser + ) + } + + else -> Text("Should never happen !") } - } + } } - }) + }) } @Composable @@ -127,23 +153,29 @@ fun QuickFixScreenTab( currentPage: Int, title: String ) { - Tab( - selected = pagerState.currentPage == currentPage, - onClick = { coroutineScope.launch { pagerState.scrollToPage(currentPage) } }, - modifier = - Modifier.padding(horizontal = 4.dp, vertical = 4.dp) - .clip(RoundedCornerShape(13.dp)) - .background( - if (pagerState.currentPage == currentPage) colorScheme.primary - else Color.Transparent) - .testTag("tab$title")) { + Tab( + selected = pagerState.currentPage == currentPage, + onClick = { coroutineScope.launch { pagerState.scrollToPage(currentPage) } }, + modifier = + Modifier + .padding(horizontal = 4.dp, vertical = 4.dp) + .clip(RoundedCornerShape(13.dp)) + .background( + if (pagerState.currentPage == currentPage) colorScheme.primary + else Color.Transparent + ) + .testTag("tab$title") + ) { Text( title, color = - if (pagerState.currentPage == currentPage) colorScheme.background - else colorScheme.tertiaryContainer, + if (pagerState.currentPage == currentPage) colorScheme.background + else colorScheme.tertiaryContainer, style = MaterialTheme.typography.titleMedium, modifier = - Modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("tabText$title")) - } + Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag("tabText$title") + ) + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/SearchFilters.kt b/app/src/main/java/com/arygm/quickfix/ui/search/SearchFilters.kt new file mode 100644 index 00000000..d39c5fa5 --- /dev/null +++ b/app/src/main/java/com/arygm/quickfix/ui/search/SearchFilters.kt @@ -0,0 +1,249 @@ +package com.arygm.quickfix.ui.search + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Handyman +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.LocationSearching +import androidx.compose.material.icons.filled.MonetizationOn +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.WorkspacePremium +import androidx.compose.material3.AlertDialogDefaults.containerColor +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.arygm.quickfix.model.locations.Location +import com.arygm.quickfix.model.profile.WorkerProfile +import com.arygm.quickfix.model.search.SearchViewModel +import com.arygm.quickfix.ui.elements.QuickFixButton +import com.arygm.quickfix.ui.theme.poppinsTypography +import java.time.LocalDate + +data class SearchFilterButtons( + val onClick: () -> Unit, + val text: String, + val leadingIcon: ImageVector? = null, + val trailingIcon: ImageVector? = null, + val applied: Boolean = false +) + +data class SearchFiltersState( + var availabilityFilterApplied: Boolean = false, + var servicesFilterApplied: Boolean = false, + var priceFilterApplied: Boolean = false, + var locationFilterApplied: Boolean = false, + var ratingFilterApplied: Boolean = false, + + var selectedDays: List = emptyList(), + var selectedHour: Int = 0, + var selectedMinute: Int = 0, + + var selectedServices: List = emptyList(), + var selectedPriceStart: Int = 0, + var selectedPriceEnd: Int = 0, + + var selectedLocation: Location = Location(), + var maxDistance: Int = 0, + + var baseLocation: Location = Location(), + var phoneLocation: Location = Location(0.0, 0.0, "Default"), +) + +@Composable +fun rememberSearchFiltersState(): SearchFiltersState { + return remember { SearchFiltersState() } +} + +/** Applies all active filters to [workerProfiles]. */ +fun SearchFiltersState.reapplyFilters( + workerProfiles: List, + searchViewModel: SearchViewModel +): List { + var updatedProfiles = workerProfiles + + if (availabilityFilterApplied) { + updatedProfiles = searchViewModel.filterWorkersByAvailability( + updatedProfiles, selectedDays, selectedHour, selectedMinute + ) + } + + if (servicesFilterApplied) { + updatedProfiles = searchViewModel.filterWorkersByServices(updatedProfiles, selectedServices) + } + + if (priceFilterApplied) { + updatedProfiles = searchViewModel.filterWorkersByPriceRange( + updatedProfiles, selectedPriceStart, selectedPriceEnd + ) + } + + if (locationFilterApplied) { + updatedProfiles = searchViewModel.filterWorkersByDistance( + updatedProfiles, selectedLocation, maxDistance + ) + } + + if (ratingFilterApplied) { + updatedProfiles = searchViewModel.sortWorkersByRating(updatedProfiles) + } + + return updatedProfiles +} + +/** Clears all active filters and returns the original [workerProfiles]. */ +fun SearchFiltersState.clearFilters( + workerProfiles: List +): List { + availabilityFilterApplied = false + priceFilterApplied = false + locationFilterApplied = false + ratingFilterApplied = false + servicesFilterApplied = false + selectedServices = emptyList() + baseLocation = phoneLocation + return workerProfiles +} + +/** + * Creates the list of filter buttons. + * + * Each button's action updates the filter state and calls [onProfilesUpdated] + * to update the displayed profiles. + */ +fun SearchFiltersState.getFilterButtons( + workerProfiles: List, + filteredProfiles: List, + searchViewModel: SearchViewModel, + onProfilesUpdated: (List) -> Unit, + onShowAvailabilityBottomSheet: () -> Unit, + onShowServicesBottomSheet: () -> Unit, + onShowPriceRangeBottomSheet: () -> Unit, + onShowLocationBottomSheet: () -> Unit +): List { + + return listOf( + SearchFilterButtons( + onClick = { + val cleared = clearFilters(workerProfiles) + onProfilesUpdated(cleared) + }, + text = "Clear", + leadingIcon = Icons.Default.Clear + ), + SearchFilterButtons( + onClick = { onShowLocationBottomSheet() }, + text = "Location", + leadingIcon = Icons.Default.LocationSearching, + trailingIcon = Icons.Default.KeyboardArrowDown, + applied = locationFilterApplied + ), + SearchFilterButtons( + onClick = { onShowServicesBottomSheet() }, + text = "Service Type", + leadingIcon = Icons.Default.Handyman, + trailingIcon = Icons.Default.KeyboardArrowDown, + applied = servicesFilterApplied + ), + SearchFilterButtons( + onClick = { onShowAvailabilityBottomSheet() }, + text = "Availability", + leadingIcon = Icons.Default.CalendarMonth, + trailingIcon = Icons.Default.KeyboardArrowDown, + applied = availabilityFilterApplied + ), + SearchFilterButtons( + onClick = { + if (ratingFilterApplied) { + ratingFilterApplied = false + onProfilesUpdated(reapplyFilters(workerProfiles, searchViewModel)) + } else { + val rated = searchViewModel.sortWorkersByRating(filteredProfiles) + ratingFilterApplied = true + onProfilesUpdated(rated) + } + }, + text = "Highest Rating", + leadingIcon = Icons.Default.WorkspacePremium, + trailingIcon = if (ratingFilterApplied) Icons.Default.Clear else null, + applied = ratingFilterApplied + ), + SearchFilterButtons( + onClick = { onShowPriceRangeBottomSheet() }, + text = "Price Range", + leadingIcon = Icons.Default.MonetizationOn, + trailingIcon = Icons.Default.KeyboardArrowDown, + applied = priceFilterApplied + ) + ) +} + +@Composable +fun FilterRow( + showFilterButtons: Boolean, + toggleFilterButtons: () -> Unit, + listOfButtons: List, + modifier: Modifier = Modifier +) { + val screenHeight = 800.dp // These could be replaced with actual dimension calculations + val screenWidth = 400.dp + + IconButton( + onClick = { toggleFilterButtons() }, + modifier = modifier.testTag("tuneButton"), + colors = IconButtonDefaults.iconButtonColors( + containerColor = + if (showFilterButtons) colorScheme.primary else colorScheme.surface + ) + ) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = "Filter", + tint = + if (showFilterButtons) colorScheme.onPrimary + else colorScheme.onBackground, + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + AnimatedVisibility(visible = showFilterButtons) { + LazyRow(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + items(listOfButtons.size) { index -> + val button = listOfButtons[index] + QuickFixButton( + buttonText = button.text, + onClickAction = button.onClick, + buttonColor = if (button.applied) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface, + textColor = if (button.applied) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onBackground, + textStyle = poppinsTypography.labelSmall.copy(fontWeight = FontWeight.Medium), + height = screenHeight * 0.05f, + leadingIcon = button.leadingIcon, + trailingIcon = button.trailingIcon, + leadingIconTint = if (button.applied) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onBackground, + trailingIconTint = if (button.applied) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onBackground, + contentPadding = PaddingValues( + vertical = 0.dp, + horizontal = screenWidth * 0.02f + ), + modifier = Modifier.testTag("filter_button_${button.text}") + ) + Spacer(modifier = Modifier.width(screenHeight * 0.01f)) + } + } + } +} diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/search/SearchOnBoarding.kt index 2580d4c4..e7c9ed46 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/search/SearchOnBoarding.kt @@ -45,163 +45,175 @@ import com.arygm.quickfix.ui.theme.poppinsTypography @Composable fun SearchOnBoarding( + onSearch: () -> Unit, + onSearchEmpty: () -> Unit, navigationActions: NavigationActions, navigationActionsRoot: NavigationActions, searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, categoryViewModel: CategoryViewModel ) { - val profiles = searchViewModel.workerProfiles.collectAsState().value - val focusManager = LocalFocusManager.current - val categories = categoryViewModel.categories.collectAsState().value - Log.d("SearchOnBoarding", "Categories: $categories") - val itemCategories = remember { categories } - val expandedStates = remember { - mutableStateListOf(*BooleanArray(itemCategories.size) { false }.toTypedArray()) - } - val listState = rememberLazyListState() + val profiles = searchViewModel.workerProfiles.collectAsState().value + val focusManager = LocalFocusManager.current + val categories = categoryViewModel.categories.collectAsState().value + Log.d("SearchOnBoarding", "Categories: $categories") + val itemCategories = remember { categories } + val expandedStates = remember { + mutableStateListOf(*BooleanArray(itemCategories.size) { false }.toTypedArray()) + } + val listState = rememberLazyListState() - var searchQuery by remember { mutableStateOf("") } - var isWindowVisible by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + var isWindowVisible by remember { mutableStateOf(false) } - // Variables for WorkerSlidingWindowContent - // These will be set when a worker profile is selected - var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerCategory by remember { mutableStateOf("Exterior Painter") } - var workerAddress by remember { mutableStateOf("Ecublens, VD") } - var description by remember { mutableStateOf("Worker description goes here.") } - var includedServices by remember { mutableStateOf(listOf("Service 1", "Service 2")) } - var addonServices by remember { mutableStateOf(listOf("Add-on 1", "Add-on 2")) } - var workerRating by remember { mutableStateOf(4.5) } - var tags by remember { mutableStateOf(listOf("Tag1", "Tag2")) } - var reviews by remember { mutableStateOf(listOf("Review 1", "Review 2")) } + // Variables for WorkerSlidingWindowContent + // These will be set when a worker profile is selected + var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } + var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } + var initialSaved by remember { mutableStateOf(false) } + var workerCategory by remember { mutableStateOf("Exterior Painter") } + var workerAddress by remember { mutableStateOf("Ecublens, VD") } + var description by remember { mutableStateOf("Worker description goes here.") } + var includedServices by remember { mutableStateOf(listOf("Service 1", "Service 2")) } + var addonServices by remember { mutableStateOf(listOf("Add-on 1", "Add-on 2")) } + var workerRating by remember { mutableStateOf(4.5) } + var tags by remember { mutableStateOf(listOf("Tag1", "Tag2")) } + var reviews by remember { mutableStateOf(listOf("Review 1", "Review 2")) } - BoxWithConstraints { - val widthRatio = maxWidth.value / 411f - val heightRatio = maxHeight.value / 860f - val sizeRatio = minOf(widthRatio, heightRatio) - val screenHeight = maxHeight - val screenWidth = maxWidth + BoxWithConstraints { + val widthRatio = maxWidth.value / 411f + val heightRatio = maxHeight.value / 860f + val sizeRatio = minOf(widthRatio, heightRatio) + val screenHeight = maxHeight + val screenWidth = maxWidth - // Use Scaffold for the layout structure - Scaffold( - containerColor = colorScheme.background, - content = { padding -> - Column( - modifier = - Modifier.fillMaxWidth() - .padding(padding) - .padding(top = 40.dp * heightRatio) - .padding(horizontal = 10.dp * widthRatio), - horizontalAlignment = Alignment.CenterHorizontally) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp * heightRatio), - horizontalArrangement = Arrangement.Center) { - QuickFixTextFieldCustom( - modifier = Modifier.testTag("searchContent"), - showLeadingIcon = { true }, - leadingIcon = Icons.Outlined.Search, - showTrailingIcon = { searchQuery.isNotEmpty() }, - trailingIcon = { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = "Clear search query", - tint = colorScheme.onBackground, - ) - }, - placeHolderText = "Find your perfect fix with QuickFix", - value = searchQuery, - onValueChange = { - searchQuery = it - searchViewModel.updateSearchQuery(it) - }, - shape = CircleShape, - textStyle = poppinsTypography.bodyMedium, - textColor = colorScheme.onBackground, - placeHolderColor = colorScheme.onBackground, - leadIconColor = colorScheme.onBackground, - widthField = 300.dp * widthRatio, - heightField = 40.dp * heightRatio, - moveContentHorizontal = 10.dp * widthRatio, - moveContentBottom = 0.dp, - moveContentTop = 0.dp, - sizeIconGroup = 30.dp * sizeRatio, - spaceBetweenLeadIconText = 0.dp, - onClick = true, - ) - Spacer(modifier = Modifier.width(10.dp * widthRatio)) - QuickFixButton( - buttonText = "Cancel", - textColor = colorScheme.onBackground, - buttonColor = colorScheme.background, - buttonOpacity = 1f, - textStyle = poppinsTypography.labelSmall, - onClickAction = { - navigationActionsRoot.navigateTo(TopLevelDestinations.HOME) - }, - contentPadding = PaddingValues(0.dp), - ) + // Use Scaffold for the layout structure + Scaffold( + containerColor = colorScheme.background, + content = { padding -> + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(padding) + .padding(top = 40.dp * heightRatio) + .padding(horizontal = 10.dp * widthRatio), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp * heightRatio), + horizontalArrangement = Arrangement.Center + ) { + QuickFixTextFieldCustom( + modifier = Modifier.testTag("searchContent"), + showLeadingIcon = { true }, + leadingIcon = Icons.Outlined.Search, + showTrailingIcon = { searchQuery.isNotEmpty() }, + trailingIcon = { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear search query", + tint = colorScheme.onBackground, + ) + }, + placeHolderText = "Find your perfect fix with QuickFix", + value = searchQuery, + onValueChange = { + searchQuery = it + searchViewModel.updateSearchQuery(it) + if (it.isEmpty()) { + onSearchEmpty() + } else { + onSearch() + } + }, + shape = CircleShape, + textStyle = poppinsTypography.bodyMedium, + textColor = colorScheme.onBackground, + placeHolderColor = colorScheme.onBackground, + leadIconColor = colorScheme.onBackground, + widthField = 300.dp * widthRatio, + heightField = 40.dp * heightRatio, + moveContentHorizontal = 10.dp * widthRatio, + moveContentBottom = 0.dp, + moveContentTop = 0.dp, + sizeIconGroup = 30.dp * sizeRatio, + spaceBetweenLeadIconText = 0.dp, + onClick = true, + ) + Spacer(modifier = Modifier.width(10.dp * widthRatio)) + QuickFixButton( + buttonText = "Cancel", + textColor = colorScheme.onBackground, + buttonColor = colorScheme.background, + buttonOpacity = 1f, + textStyle = poppinsTypography.labelSmall, + onClickAction = { + navigationActionsRoot.navigateTo(TopLevelDestinations.HOME) + }, + contentPadding = PaddingValues(0.dp), + ) + } + if (searchQuery.isEmpty()) { + // Show Categories + CategoryContent( + navigationActions = navigationActions, + searchViewModel = searchViewModel, + listState = listState, + expandedStates = expandedStates, + itemCategories = itemCategories, + widthRatio = widthRatio, + heightRatio = heightRatio, + ) + } else { + // Show Profiles + ProfileResults( + profiles = profiles, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + listState = listState, + heightRatio = heightRatio, + onBookClick = { selectedProfile -> + // Set up variables for WorkerSlidingWindowContent + bannerImage = R.drawable.moroccan_flag + profilePicture = R.drawable.placeholder_worker + initialSaved = false + workerCategory = selectedProfile.fieldOfWork + workerAddress = selectedProfile.location?.name ?: "Unknown" + description = selectedProfile.description + includedServices = selectedProfile.includedServices.map { it.name } + addonServices = selectedProfile.addOnServices.map { it.name } + workerRating = selectedProfile.rating + tags = selectedProfile.tags + reviews = selectedProfile.reviews.map { it.review } + isWindowVisible = true + }) } - if (searchQuery.isEmpty()) { - // Show Categories - CategoryContent( - navigationActions = navigationActions, - searchViewModel = searchViewModel, - listState = listState, - expandedStates = expandedStates, - itemCategories = itemCategories, - widthRatio = widthRatio, - heightRatio = heightRatio, - ) - } else { - // Show Profiles - ProfileResults( - profiles = profiles, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - listState = listState, - heightRatio = heightRatio, - onBookClick = { selectedProfile -> - // Set up variables for WorkerSlidingWindowContent - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerCategory = selectedProfile.fieldOfWork - workerAddress = selectedProfile.location?.name ?: "Unknown" - description = selectedProfile.description - includedServices = selectedProfile.includedServices.map { it.name } - addonServices = selectedProfile.addOnServices.map { it.name } - workerRating = selectedProfile.rating - tags = selectedProfile.tags - reviews = selectedProfile.reviews.map { it.review } - isWindowVisible = true - }) } - } - }, - modifier = + }, + modifier = Modifier.pointerInput(Unit) { - detectTapGestures(onTap = { focusManager.clearFocus() }) + detectTapGestures(onTap = { focusManager.clearFocus() }) }) - QuickFixSlidingWindowWorker( - isVisible = isWindowVisible, - onDismiss = { isWindowVisible = false }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = initialSaved, - workerCategory = workerCategory, - workerAddress = workerAddress, - description = description, - includedServices = includedServices, - addonServices = addonServices, - workerRating = workerRating, - tags = tags, - reviews = reviews, - screenHeight = screenHeight, - screenWidth = screenWidth, - onContinueClick = { /* Handle continue */}) - } + QuickFixSlidingWindowWorker( + isVisible = isWindowVisible, + onDismiss = { isWindowVisible = false }, + bannerImage = bannerImage, + profilePicture = profilePicture, + initialSaved = initialSaved, + workerCategory = workerCategory, + workerAddress = workerAddress, + description = description, + includedServices = includedServices, + addonServices = addonServices, + workerRating = workerRating, + tags = tags, + reviews = reviews, + screenHeight = screenHeight, + screenWidth = screenWidth, + onContinueClick = { /* Handle continue */ }) + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/search/SearchWorkerResult.kt index 86e8e7b8..71f76cf8 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/search/SearchWorkerResult.kt @@ -3,37 +3,23 @@ package com.arygm.quickfix.ui.search import QuickFixSlidingWindowWorker import android.util.Log import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Handyman -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.LocationSearching -import androidx.compose.material.icons.filled.MonetizationOn import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.filled.WorkspacePremium import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Scaffold @@ -48,12 +34,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arygm.quickfix.MainActivity import com.arygm.quickfix.R @@ -68,25 +52,17 @@ import com.arygm.quickfix.model.profile.dataFields.Review import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.elements.ChooseServiceTypeSheet import com.arygm.quickfix.ui.elements.QuickFixAvailabilityBottomSheet -import com.arygm.quickfix.ui.elements.QuickFixButton import com.arygm.quickfix.ui.elements.QuickFixLocationFilterBottomSheet import com.arygm.quickfix.ui.elements.QuickFixPriceRangeBottomSheet import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography import com.arygm.quickfix.utils.LocationHelper import com.arygm.quickfix.utils.loadUserId -import java.time.LocalDate import java.time.LocalTime -data class SearchFilterButtons( - val onClick: () -> Unit, - val text: String, - val leadingIcon: ImageVector? = null, - val trailingIcon: ImageVector? = null, - val applied: Boolean = false +@OptIn( + ExperimentalMaterial3Api::class, ) - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun SearchWorkerResult( navigationActions: NavigationActions, @@ -95,296 +71,166 @@ fun SearchWorkerResult( userProfileViewModel: ProfileViewModel, preferencesViewModel: PreferencesViewModel ) { - val locationHelper = LocationHelper(LocalContext.current, MainActivity()) - var phoneLocation by remember { - mutableStateOf(com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) - } - var baseLocation by remember { mutableStateOf(phoneLocation) } - val context = LocalContext.current - LaunchedEffect(Unit) { - if (locationHelper.checkPermissions()) { - locationHelper.getCurrentLocation { location -> - if (location != null) { - phoneLocation = - com.arygm.quickfix.model.locations.Location( - location.latitude, location.longitude, "Phone Location") - baseLocation = phoneLocation - } else { - Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() + val context = LocalContext.current + val locationHelper = LocationHelper(context, MainActivity()) + + val filterState = rememberSearchFiltersState() + + val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() + var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } + + var showFilterButtons by remember { mutableStateOf(false) } + var showAvailabilityBottomSheet by remember { mutableStateOf(false) } + var showServicesBottomSheet by remember { mutableStateOf(false) } + var showPriceRangeBottomSheet by remember { mutableStateOf(false) } + var showLocationBottomSheet by remember { mutableStateOf(false) } + + var isWindowVisible by remember { mutableStateOf(false) } + var saved by remember { mutableStateOf(false) } + + var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } + var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } + var initialSaved by remember { mutableStateOf(false) } + var workerCategory by remember { mutableStateOf("Exterior Painter") } + var workerAddress by remember { mutableStateOf("Ecublens, VD") } + var description by remember { mutableStateOf("Worker description goes here.") } + var includedServices by remember { mutableStateOf(listOf()) } + var addonServices by remember { mutableStateOf(listOf()) } + var workerRating by remember { mutableStateOf(4.5) } + var tags by remember { mutableStateOf(listOf()) } + var reviews by remember { mutableStateOf(listOf()) } + + // User and location setup + var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") + var uid by remember { mutableStateOf("Loading...") } + + val searchQuery by searchViewModel.searchQuery.collectAsState() + val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() + + // Fetch user id and profile + LaunchedEffect(Unit) { + uid = loadUserId(preferencesViewModel) + userProfileViewModel.fetchUserProfile(uid) { profile -> + if (profile is UserProfile) { + userProfile = profile + } else { + Log.e("SearchWorkerResult", "Fetched a worker profile from a user profile repo.") + } } - } - } else { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() } - } - - var showFilterButtons by remember { mutableStateOf(false) } - var showAvailabilityBottomSheet by remember { mutableStateOf(false) } - var showServicesBottomSheet by remember { mutableStateOf(false) } - var showPriceRangeBottomSheet by remember { mutableStateOf(false) } - var showLocationBottomSheet by remember { mutableStateOf(false) } - val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() - var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } - var isWindowVisible by remember { mutableStateOf(false) } - var saved by remember { mutableStateOf(false) } - - var availabilityFilterApplied by remember { mutableStateOf(false) } - var servicesFilterApplied by remember { mutableStateOf(false) } - var priceFilterApplied by remember { mutableStateOf(false) } - var locationFilterApplied by remember { mutableStateOf(false) } - var ratingFilterApplied by remember { mutableStateOf(false) } - var selectedDays by remember { mutableStateOf(emptyList()) } - var selectedHour by remember { mutableStateOf(0) } - var selectedMinute by remember { mutableStateOf(0) } - var selectedServices by remember { mutableStateOf(emptyList()) } - var selectedPriceStart by remember { mutableStateOf(0) } - var selectedPriceEnd by remember { mutableStateOf(0) } - var selectedLocation by remember { mutableStateOf(com.arygm.quickfix.model.locations.Location()) } - var maxDistance by remember { mutableStateOf(0) } - - fun reapplyFilters() { - var updatedProfiles = workerProfiles - - if (availabilityFilterApplied) { - updatedProfiles = - searchViewModel.filterWorkersByAvailability( - updatedProfiles, selectedDays, selectedHour, selectedMinute) - } - - if (servicesFilterApplied) { - updatedProfiles = searchViewModel.filterWorkersByServices(updatedProfiles, selectedServices) - } - - if (priceFilterApplied) { - updatedProfiles = - searchViewModel.filterWorkersByPriceRange( - updatedProfiles, selectedPriceStart, selectedPriceEnd) - } - - if (locationFilterApplied) { - updatedProfiles = - searchViewModel.filterWorkersByDistance(updatedProfiles, selectedLocation, maxDistance) - } - - if (ratingFilterApplied) { - updatedProfiles = searchViewModel.sortWorkersByRating(updatedProfiles) - } - - filteredWorkerProfiles = updatedProfiles - } - - val listOfButtons = - listOf( - SearchFilterButtons( - onClick = { - filteredWorkerProfiles = workerProfiles - availabilityFilterApplied = false - priceFilterApplied = false - locationFilterApplied = false - ratingFilterApplied = false - servicesFilterApplied = false - selectedServices = emptyList() - baseLocation = phoneLocation - }, - text = "Clear", - leadingIcon = Icons.Default.Clear, - applied = false), - SearchFilterButtons( - onClick = { showLocationBottomSheet = true }, - text = "Location", - leadingIcon = Icons.Default.LocationSearching, - trailingIcon = Icons.Default.KeyboardArrowDown, - applied = locationFilterApplied), - SearchFilterButtons( - onClick = { showServicesBottomSheet = true }, - text = "Service Type", - leadingIcon = Icons.Default.Handyman, - trailingIcon = Icons.Default.KeyboardArrowDown, - applied = servicesFilterApplied), - SearchFilterButtons( - onClick = { showAvailabilityBottomSheet = true }, - text = "Availability", - leadingIcon = Icons.Default.CalendarMonth, - trailingIcon = Icons.Default.KeyboardArrowDown, - applied = availabilityFilterApplied), - SearchFilterButtons( - onClick = { - if (ratingFilterApplied) { - ratingFilterApplied = false - reapplyFilters() + // Location initialization + LaunchedEffect(Unit) { + if (locationHelper.checkPermissions()) { + locationHelper.getCurrentLocation { location -> + if (location != null) { + val userLoc = com.arygm.quickfix.model.locations.Location( + location.latitude, location.longitude, "Phone Location" + ) + filterState.phoneLocation = userLoc + filterState.baseLocation = userLoc } else { - filteredWorkerProfiles = - searchViewModel.sortWorkersByRating(filteredWorkerProfiles) - ratingFilterApplied = true + Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() } - }, - text = "Highest Rating", - leadingIcon = Icons.Default.WorkspacePremium, - trailingIcon = if (ratingFilterApplied) Icons.Default.Clear else null, - applied = ratingFilterApplied), - SearchFilterButtons( - onClick = { showPriceRangeBottomSheet = true }, - text = "Price Range", - leadingIcon = Icons.Default.MonetizationOn, - trailingIcon = Icons.Default.KeyboardArrowDown, - applied = priceFilterApplied), - ) - - val searchQuery by searchViewModel.searchQuery.collectAsState() - val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() - - var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") - var uid by remember { mutableStateOf("Loading...") } - - LaunchedEffect(Unit) { uid = loadUserId(preferencesViewModel) } - userProfileViewModel.fetchUserProfile(uid) { profile -> - if (profile is UserProfile) { - userProfile = profile - } else { - Log.e("SearchWorkerResult", "Fetched a worker profile from a user profile repo.") + } + } else { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } } - } - - // Wrap everything in a Box to allow overlay - val listState = rememberLazyListState() - // Variables for Sliding Window - var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerCategory by remember { mutableStateOf("Exterior Painter") } - var workerAddress by remember { mutableStateOf("Ecublens, VD") } - var description by remember { mutableStateOf("Worker description goes here.") } - var includedServices by remember { mutableStateOf(listOf()) } - var addonServices by remember { mutableStateOf(listOf()) } - var workerRating by remember { mutableStateOf(4.5) } - var tags by remember { mutableStateOf(listOf()) } - var reviews by remember { mutableStateOf(listOf()) } + val listState = rememberLazyListState() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val screenHeight = maxHeight - val screenWidth = maxWidth - Log.d("Screen Dimensions", "Height: $screenHeight, Width: $screenWidth") + fun updateFilteredProfiles() { + filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) + } - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { - Text(text = "Search Results", style = MaterialTheme.typography.titleMedium) - }, - navigationIcon = { - IconButton(onClick = { navigationActions.goBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { /* Handle search */}) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = colorScheme.onBackground) - } - }, - colors = - TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = colorScheme.background), - ) - }) { paddingValues -> - // Main content inside the Scaffold - Column( - modifier = Modifier.fillMaxWidth().padding(paddingValues), - horizontalAlignment = Alignment.CenterHorizontally) { + // Build the list of filter buttons through the filter state + val listOfButtons = filterState.getFilterButtons( + workerProfiles = workerProfiles, + filteredProfiles = filteredWorkerProfiles, + searchViewModel = searchViewModel, + onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, + onShowAvailabilityBottomSheet = { showAvailabilityBottomSheet = true }, + onShowServicesBottomSheet = { showServicesBottomSheet = true }, + onShowPriceRangeBottomSheet = { showPriceRangeBottomSheet = true }, + onShowLocationBottomSheet = { showLocationBottomSheet = true }, + ) + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenHeight = maxHeight + val screenWidth = maxWidth + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text(text = "Search Results", style = MaterialTheme.typography.titleMedium) + }, + navigationIcon = { + IconButton(onClick = { navigationActions.goBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + IconButton(onClick = { /* Handle search */ }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = colorScheme.onBackground + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = colorScheme.background + ), + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally + ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top) { - Text( - text = searchQuery, - style = poppinsTypography.labelMedium, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - Text( - text = "This is a sample description for the $searchQuery result", - style = poppinsTypography.labelSmall, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - color = colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - } + verticalArrangement = Arrangement.Top + ) { + Text( + text = searchQuery, + style = poppinsTypography.labelMedium, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Text( + text = "This is a sample description for the $searchQuery result", + style = poppinsTypography.labelSmall, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + color = colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } Row( - modifier = - Modifier.fillMaxWidth() - .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) - .padding(horizontal = screenWidth * 0.02f) - .wrapContentHeight() - .testTag("filter_buttons_row"), + modifier = Modifier + .fillMaxWidth() + .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) + .padding(horizontal = screenWidth * 0.02f) + .testTag("filter_buttons_row"), verticalAlignment = Alignment.CenterVertically, ) { - // Tune Icon - fixed, non-scrollable - IconButton( - onClick = { showFilterButtons = !showFilterButtons }, - modifier = - Modifier.padding(bottom = screenHeight * 0.01f).testTag("tuneButton"), - content = { - Icon( - imageVector = Icons.Default.Tune, - contentDescription = "Filter", - tint = - if (showFilterButtons) colorScheme.onPrimary - else colorScheme.onBackground, - ) - }, - colors = - IconButtonDefaults.iconButtonColors( - containerColor = - if (showFilterButtons) colorScheme.primary - else colorScheme.surface), - ) - - Spacer(modifier = Modifier.width(10.dp)) - - AnimatedVisibility(visible = showFilterButtons) { - LazyRow( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.testTag("lazy_filter_row")) { - items(listOfButtons.size) { index -> - QuickFixButton( - buttonText = listOfButtons[index].text, - onClickAction = listOfButtons[index].onClick, - buttonColor = - if (listOfButtons[index].applied) colorScheme.primary - else colorScheme.surface, - textColor = - if (listOfButtons[index].applied) colorScheme.onPrimary - else colorScheme.onBackground, - textStyle = - poppinsTypography.labelSmall.copy( - fontWeight = FontWeight.Medium), - height = screenHeight * 0.05f, - leadingIcon = listOfButtons[index].leadingIcon, - trailingIcon = listOfButtons[index].trailingIcon, - leadingIconTint = - if (listOfButtons[index].applied) colorScheme.onPrimary - else colorScheme.onBackground, - trailingIconTint = - if (listOfButtons[index].applied) colorScheme.onPrimary - else colorScheme.onBackground, - contentPadding = - PaddingValues( - vertical = 0.dp, horizontal = screenWidth * 0.02f), - modifier = - Modifier.testTag("filter_button_${listOfButtons[index].text}")) - Spacer(modifier = Modifier.width(screenHeight * 0.01f)) - } - } - } + FilterRow( + showFilterButtons = showFilterButtons, + toggleFilterButtons = { showFilterButtons = !showFilterButtons }, + listOfButtons = listOfButtons, + modifier = Modifier.padding(bottom = screenHeight * 0.01f) + ) } ProfileResults( @@ -395,164 +241,158 @@ fun SearchWorkerResult( accountViewModel = accountViewModel, heightRatio = 1f, onBookClick = { selectedProfile -> + // Mock data for demonstration, replace with actual data + val profile = WorkerProfile( + rating = 4.8, + fieldOfWork = "Exterior Painter", + description = "Worker description goes here.", + location = com.arygm.quickfix.model.locations.Location( + 12.0, 12.0, "Ecublens, VD" + ), + quickFixes = listOf("Painting", "Gardening"), + includedServices = listOf( + IncludedService("Painting"), + IncludedService("Gardening"), + ), + addOnServices = listOf( + AddOnService("Furniture Assembly"), + AddOnService("Window Cleaning"), + ), + reviews = ArrayDeque( + listOf( + Review("Bob", "nice work", 4.0f), + Review("Alice", "bad work", 3.5f), + ) + ), + profilePicture = "placeholder_worker", + price = 130.0, + displayName = "John Doe", + unavailability_list = emptyList(), + workingHours = Pair(LocalTime.now(), LocalTime.now()), + uid = "1234", + tags = listOf("Painter", "Gardener"), + ) - // TODO when linking the backend remove placeHolder data - val profile = - WorkerProfile( - rating = 4.8, - fieldOfWork = "Exterior Painter", - description = "Worker description goes here.", - location = - com.arygm.quickfix.model.locations.Location( - 12.0, 12.0, "Ecublens, VD"), - quickFixes = listOf("Painting", "Gardening"), - includedServices = - listOf( - IncludedService("Painting"), - IncludedService("Gardening"), - ), - addOnServices = - listOf( - AddOnService("Furniture Assembly"), - AddOnService("Window Cleaning"), - ), - reviews = - ArrayDeque( - listOf( - Review("Bob", "nice work", 4.0f), - Review("Alice", "bad work", 3.5f), - )), - profilePicture = "placeholder_worker", - price = 130.0, - displayName = "John Doe", - unavailability_list = emptyList(), - workingHours = Pair(LocalTime.now(), LocalTime.now()), - uid = "1234", - tags = listOf("Painter", "Gardener"), - ) - - // Update variables for Sliding Window - bannerImage = R.drawable.moroccan_flag // Replace with actual data - profilePicture = R.drawable.placeholder_worker // Replace with actual data - initialSaved = false // Replace with actual data - workerCategory = profile.fieldOfWork - workerAddress = profile.location?.name ?: "Unknown" - description = profile.description - - includedServices = profile.includedServices.map { it.name } - - addonServices = profile.addOnServices.map { it.name } - - workerRating = profile.rating - tags = profile.tags - - reviews = profile.reviews.map { it.review } - - isWindowVisible = true - }) - } + bannerImage = R.drawable.moroccan_flag + profilePicture = R.drawable.placeholder_worker + initialSaved = false + workerCategory = profile.fieldOfWork + workerAddress = profile.location?.name ?: "Unknown" + description = profile.description + includedServices = profile.includedServices.map { it.name } + addonServices = profile.addOnServices.map { it.name } + workerRating = profile.rating + tags = profile.tags + reviews = profile.reviews.map { it.review } + + isWindowVisible = true + } + ) + } } - QuickFixAvailabilityBottomSheet( - showAvailabilityBottomSheet, - onDismissRequest = { showAvailabilityBottomSheet = false }, - onOkClick = { days, hour, minute -> - selectedDays = days - selectedHour = hour - selectedMinute = minute - filteredWorkerProfiles = - searchViewModel.filterWorkersByAvailability( - filteredWorkerProfiles, days, hour, minute) - availabilityFilterApplied = true - }, - onClearClick = { - availabilityFilterApplied = false - selectedDays = emptyList() - selectedHour = 0 - selectedMinute = 0 - reapplyFilters() - }, - clearEnabled = availabilityFilterApplied) + // Bottom sheets for filters + QuickFixAvailabilityBottomSheet( + showAvailabilityBottomSheet, + onDismissRequest = { showAvailabilityBottomSheet = false }, + onOkClick = { days, hour, minute -> + filterState.selectedDays = days + filterState.selectedHour = hour + filterState.selectedMinute = minute + filterState.availabilityFilterApplied = true + updateFilteredProfiles() + }, + onClearClick = { + filterState.availabilityFilterApplied = false + filterState.selectedDays = emptyList() + filterState.selectedHour = 0 + filterState.selectedMinute = 0 + updateFilteredProfiles() + }, + clearEnabled = filterState.availabilityFilterApplied + ) + + searchSubcategory?.let { + ChooseServiceTypeSheet( + showServicesBottomSheet, + it.tags, + selectedServices = filterState.selectedServices, + onApplyClick = { services -> + filterState.selectedServices = services + filterState.servicesFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showServicesBottomSheet = false }, + onClearClick = { + filterState.selectedServices = emptyList() + filterState.servicesFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.servicesFilterApplied + ) + } - searchSubcategory?.let { - ChooseServiceTypeSheet( - showServicesBottomSheet, - it.tags, - selectedServices = selectedServices, - onApplyClick = { services -> - selectedServices = services - filteredWorkerProfiles = - searchViewModel.filterWorkersByServices(filteredWorkerProfiles, selectedServices) - servicesFilterApplied = true - }, - onDismissRequest = { showServicesBottomSheet = false }, - onClearClick = { - selectedServices = emptyList() - servicesFilterApplied = false - reapplyFilters() - }, - clearEnabled = servicesFilterApplied) + QuickFixPriceRangeBottomSheet( + showPriceRangeBottomSheet, + onApplyClick = { start, end -> + filterState.selectedPriceStart = start + filterState.selectedPriceEnd = end + filterState.priceFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showPriceRangeBottomSheet = false }, + onClearClick = { + filterState.selectedPriceStart = 0 + filterState.selectedPriceEnd = 0 + filterState.priceFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.priceFilterApplied + ) + + QuickFixLocationFilterBottomSheet( + showLocationBottomSheet, + userProfile = userProfile, + phoneLocation = filterState.phoneLocation, + onApplyClick = { location, max -> + filterState.selectedLocation = location + if (location == com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT) + .show() + } + filterState.baseLocation = location + filterState.maxDistance = max + filterState.locationFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showLocationBottomSheet = false }, + onClearClick = { + filterState.baseLocation = filterState.phoneLocation + filterState.selectedLocation = com.arygm.quickfix.model.locations.Location() + filterState.maxDistance = 0 + filterState.locationFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.locationFilterApplied + ) + + QuickFixSlidingWindowWorker( + isVisible = isWindowVisible, + onDismiss = { isWindowVisible = false }, + bannerImage = bannerImage, + profilePicture = profilePicture, + initialSaved = saved, + workerCategory = workerCategory, + workerAddress = workerAddress, + description = description, + includedServices = includedServices, + addonServices = addonServices, + workerRating = workerRating, + tags = tags, + reviews = reviews, + screenHeight = maxHeight, + screenWidth = maxWidth, + onContinueClick = {} + ) } - - QuickFixPriceRangeBottomSheet( - showPriceRangeBottomSheet, - onApplyClick = { start, end -> - selectedPriceStart = start - selectedPriceEnd = end - filteredWorkerProfiles = - searchViewModel.filterWorkersByPriceRange(filteredWorkerProfiles, start, end) - priceFilterApplied = true - }, - onDismissRequest = { showPriceRangeBottomSheet = false }, - onClearClick = { - selectedPriceStart = 0 - selectedPriceEnd = 0 - priceFilterApplied = false - reapplyFilters() - }, - clearEnabled = priceFilterApplied) - - QuickFixLocationFilterBottomSheet( - showLocationBottomSheet, - userProfile = userProfile, - phoneLocation = phoneLocation, - onApplyClick = { location, max -> - selectedLocation = location - if (location == com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() - } - baseLocation = location - maxDistance = max - filteredWorkerProfiles = - searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) - locationFilterApplied = true - }, - onDismissRequest = { showLocationBottomSheet = false }, - onClearClick = { - baseLocation = phoneLocation - selectedLocation = com.arygm.quickfix.model.locations.Location() - maxDistance = 0 - locationFilterApplied = false - reapplyFilters() - }, - clearEnabled = locationFilterApplied) - - QuickFixSlidingWindowWorker( - isVisible = isWindowVisible, - onDismiss = { isWindowVisible = false }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = saved, - workerCategory = workerCategory, - workerAddress = workerAddress, - description = description, - includedServices = includedServices, - addonServices = addonServices, - workerRating = workerRating, - tags = tags, - reviews = reviews, - screenHeight = screenHeight, - screenWidth = screenWidth, - onContinueClick = { /* Handle continue */}) - } } From 2687c15d056ae37b67b02352c76eb4bf827e9cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Thu, 12 Dec 2024 14:02:34 +0100 Subject: [PATCH 02/15] Fix/Feat: UI fix and added filtering tabs to SearchOnBoarding.kt --- .../quickfix/ui/search/ProfileResults.kt | 99 ++-- .../quickfix/ui/search/QuickFixFinder.kt | 179 +++--- .../arygm/quickfix/ui/search/SearchFilters.kt | 269 ++++----- .../quickfix/ui/search/SearchOnBoarding.kt | 443 +++++++++----- .../quickfix/ui/search/SearchWorkerResult.kt | 556 +++++++++--------- 5 files changed, 811 insertions(+), 735 deletions(-) diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/ProfileResults.kt b/app/src/main/java/com/arygm/quickfix/ui/search/ProfileResults.kt index c251548c..cb65ea4e 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/search/ProfileResults.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/search/ProfileResults.kt @@ -32,62 +32,59 @@ fun ProfileResults( listState: LazyListState, searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, - heightRatio: Float, onBookClick: (WorkerProfile) -> Unit ) { - // LazyColumn for displaying profiles - LazyColumn(modifier = modifier.fillMaxWidth(), state = listState) { - items(profiles.size) { index -> - val profile = profiles[index] - var account by remember { mutableStateOf(null) } - var distance by remember { mutableStateOf(null) } + // LazyColumn for displaying profiles + LazyColumn(modifier = modifier.fillMaxWidth(), state = listState) { + items(profiles.size) { index -> + val profile = profiles[index] + var account by remember { mutableStateOf(null) } + var distance by remember { mutableStateOf(null) } - // Get user's current location and calculate distance - val locationHelper = LocationHelper(LocalContext.current, MainActivity()) - locationHelper.getCurrentLocation { location -> - location?.let { - distance = - profile.location?.let { workerLocation -> - searchViewModel - .calculateDistance( - workerLocation.latitude, - workerLocation.longitude, - it.latitude, - it.longitude - ) - .toInt() - } - } - } + // Get user's current location and calculate distance + val locationHelper = LocationHelper(LocalContext.current, MainActivity()) + locationHelper.getCurrentLocation { location -> + location?.let { + distance = + profile.location?.let { workerLocation -> + searchViewModel + .calculateDistance( + workerLocation.latitude, + workerLocation.longitude, + it.latitude, + it.longitude) + .toInt() + } + } + } - // Fetch user account details - LaunchedEffect(profile.uid) { - accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> - account = fetchedAccount - } - } + // Fetch user account details + LaunchedEffect(profile.uid) { + accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> + account = fetchedAccount + } + } - // Render profile card if account data is available - account?.let { acc -> - SearchWorkerProfileResult( - modifier = - Modifier - .padding(vertical = 0.dp) - .fillMaxWidth() - .testTag("worker_profile_result_$index") - .clickable {}, - profileImage = R.drawable.placeholder_worker, - name = "${acc.firstName} ${acc.lastName}", - category = profile.fieldOfWork, - rating = profile.rating, - reviewCount = profile.reviews.size, - location = profile.location?.name ?: "Unknown", - price = profile.price.toString(), - distance = distance, - onBookClick = { onBookClick(profile) }) - } + // Render profile card if account data is available + account?.let { acc -> + SearchWorkerProfileResult( + modifier = + Modifier.padding(vertical = 0.dp) + .fillMaxWidth() + .testTag("worker_profile_result_$index") + .clickable {}, + profileImage = R.drawable.placeholder_worker, + name = "${acc.firstName} ${acc.lastName}", + category = profile.fieldOfWork, + rating = profile.rating, + reviewCount = profile.reviews.size, + location = profile.location?.name ?: "Unknown", + price = profile.price.toString(), + distance = distance, + onBookClick = { onBookClick(profile) }) + } - Spacer(modifier = Modifier.height(0.dp)) - } + Spacer(modifier = Modifier.height(0.dp)) } + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/search/QuickFixFinder.kt index dbd4be04..5b521152 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/search/QuickFixFinder.kt @@ -57,93 +57,78 @@ fun QuickFixFinderScreen( viewModel(factory = AnnouncementViewModel.Factory), categoryViewModel: CategoryViewModel = viewModel(factory = CategoryViewModel.Factory) ) { - var pager by remember { mutableStateOf(true) } - Scaffold( - containerColor = colorScheme.background, - topBar = { - TopAppBar( - title = { - Text( - text = "Quickfix", - color = colorScheme.primary, - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier.testTag("QuickFixFinderTopBarTitle") - ) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.background), - modifier = Modifier.testTag("QuickFixFinderTopBar") - ) - }, - content = { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .testTag("QuickFixFinderContent") - .padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - val pagerState = rememberPagerState(pageCount = { 2 }) - val coroutineScope = rememberCoroutineScope() + var pager by remember { mutableStateOf(true) } + Scaffold( + containerColor = colorScheme.background, + topBar = { + TopAppBar( + title = { + Text( + text = "Quickfix", + color = colorScheme.primary, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.testTag("QuickFixFinderTopBarTitle")) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.background), + modifier = Modifier.testTag("QuickFixFinderTopBar")) + }, + content = { padding -> + Column( + modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { + val pagerState = rememberPagerState(pageCount = { 2 }) + val coroutineScope = rememberCoroutineScope() - if (pager) { - Surface( - color = colorScheme.surface, - shape = RoundedCornerShape(20.dp), - modifier = Modifier - .padding(horizontal = 40.dp) - .clip(RoundedCornerShape(20.dp)) - ) { - TabRow( - selectedTabIndex = pagerState.currentPage, - containerColor = Color.Transparent, - divider = {}, - indicator = {}, - modifier = - Modifier - .padding(horizontal = 1.dp, vertical = 1.dp) - .align(Alignment.CenterHorizontally) - .testTag("quickFixSearchTabRow") - ) { + if (pager) { + Surface( + color = colorScheme.surface, + shape = RoundedCornerShape(20.dp), + modifier = + Modifier.padding(horizontal = 40.dp).clip(RoundedCornerShape(20.dp))) { + TabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = Color.Transparent, + divider = {}, + indicator = {}, + modifier = + Modifier.padding(horizontal = 1.dp, vertical = 1.dp) + .align(Alignment.CenterHorizontally) + .testTag("quickFixSearchTabRow")) { QuickFixScreenTab(pagerState, coroutineScope, 0, "Search") QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce") - } + } } - } - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - modifier = Modifier.testTag("quickFixSearchPager") - ) { page -> + } + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.testTag("quickFixSearchPager")) { page -> when (page) { - 0 -> { - SearchOnBoarding( - onSearch = { pager = false }, - onSearchEmpty = { pager = true }, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel - ) - } - - 1 -> { - AnnouncementScreen( - announcementViewModel, - loggedInAccountViewModel, - profileViewModel, - accountViewModel, - navigationActions, - isUser - ) - } - - else -> Text("Should never happen !") + 0 -> { + SearchOnBoarding( + onSearch = { pager = false }, + onSearchEmpty = { pager = true }, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel) + } + 1 -> { + AnnouncementScreen( + announcementViewModel, + loggedInAccountViewModel, + profileViewModel, + accountViewModel, + navigationActions, + isUser) + } + else -> Text("Should never happen !") } - } + } } - }) + }) } @Composable @@ -153,29 +138,23 @@ fun QuickFixScreenTab( currentPage: Int, title: String ) { - Tab( - selected = pagerState.currentPage == currentPage, - onClick = { coroutineScope.launch { pagerState.scrollToPage(currentPage) } }, - modifier = - Modifier - .padding(horizontal = 4.dp, vertical = 4.dp) - .clip(RoundedCornerShape(13.dp)) - .background( - if (pagerState.currentPage == currentPage) colorScheme.primary - else Color.Transparent - ) - .testTag("tab$title") - ) { + Tab( + selected = pagerState.currentPage == currentPage, + onClick = { coroutineScope.launch { pagerState.scrollToPage(currentPage) } }, + modifier = + Modifier.padding(horizontal = 4.dp, vertical = 4.dp) + .clip(RoundedCornerShape(13.dp)) + .background( + if (pagerState.currentPage == currentPage) colorScheme.primary + else Color.Transparent) + .testTag("tab$title")) { Text( title, color = - if (pagerState.currentPage == currentPage) colorScheme.background - else colorScheme.tertiaryContainer, + if (pagerState.currentPage == currentPage) colorScheme.background + else colorScheme.tertiaryContainer, style = MaterialTheme.typography.titleMedium, modifier = - Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .testTag("tabText$title") - ) - } + Modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("tabText$title")) + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/SearchFilters.kt b/app/src/main/java/com/arygm/quickfix/ui/search/SearchFilters.kt index d39c5fa5..8ed4acab 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/search/SearchFilters.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/search/SearchFilters.kt @@ -14,11 +14,9 @@ import androidx.compose.material.icons.filled.LocationSearching import androidx.compose.material.icons.filled.MonetizationOn import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.WorkspacePremium -import androidx.compose.material3.AlertDialogDefaults.containerColor import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -48,25 +46,21 @@ data class SearchFiltersState( var priceFilterApplied: Boolean = false, var locationFilterApplied: Boolean = false, var ratingFilterApplied: Boolean = false, - var selectedDays: List = emptyList(), var selectedHour: Int = 0, var selectedMinute: Int = 0, - var selectedServices: List = emptyList(), var selectedPriceStart: Int = 0, var selectedPriceEnd: Int = 0, - var selectedLocation: Location = Location(), var maxDistance: Int = 0, - var baseLocation: Location = Location(), var phoneLocation: Location = Location(0.0, 0.0, "Default"), ) @Composable fun rememberSearchFiltersState(): SearchFiltersState { - return remember { SearchFiltersState() } + return remember { SearchFiltersState() } } /** Applies all active filters to [workerProfiles]. */ @@ -74,56 +68,53 @@ fun SearchFiltersState.reapplyFilters( workerProfiles: List, searchViewModel: SearchViewModel ): List { - var updatedProfiles = workerProfiles - - if (availabilityFilterApplied) { - updatedProfiles = searchViewModel.filterWorkersByAvailability( - updatedProfiles, selectedDays, selectedHour, selectedMinute - ) - } - - if (servicesFilterApplied) { - updatedProfiles = searchViewModel.filterWorkersByServices(updatedProfiles, selectedServices) - } - - if (priceFilterApplied) { - updatedProfiles = searchViewModel.filterWorkersByPriceRange( - updatedProfiles, selectedPriceStart, selectedPriceEnd - ) - } - - if (locationFilterApplied) { - updatedProfiles = searchViewModel.filterWorkersByDistance( - updatedProfiles, selectedLocation, maxDistance - ) - } - - if (ratingFilterApplied) { - updatedProfiles = searchViewModel.sortWorkersByRating(updatedProfiles) - } - - return updatedProfiles + var updatedProfiles = workerProfiles + + if (availabilityFilterApplied) { + updatedProfiles = + searchViewModel.filterWorkersByAvailability( + updatedProfiles, selectedDays, selectedHour, selectedMinute) + } + + if (servicesFilterApplied) { + updatedProfiles = searchViewModel.filterWorkersByServices(updatedProfiles, selectedServices) + } + + if (priceFilterApplied) { + updatedProfiles = + searchViewModel.filterWorkersByPriceRange( + updatedProfiles, selectedPriceStart, selectedPriceEnd) + } + + if (locationFilterApplied) { + updatedProfiles = + searchViewModel.filterWorkersByDistance(updatedProfiles, selectedLocation, maxDistance) + } + + if (ratingFilterApplied) { + updatedProfiles = searchViewModel.sortWorkersByRating(updatedProfiles) + } + + return updatedProfiles } /** Clears all active filters and returns the original [workerProfiles]. */ -fun SearchFiltersState.clearFilters( - workerProfiles: List -): List { - availabilityFilterApplied = false - priceFilterApplied = false - locationFilterApplied = false - ratingFilterApplied = false - servicesFilterApplied = false - selectedServices = emptyList() - baseLocation = phoneLocation - return workerProfiles +fun SearchFiltersState.clearFilters(workerProfiles: List): List { + availabilityFilterApplied = false + priceFilterApplied = false + locationFilterApplied = false + ratingFilterApplied = false + servicesFilterApplied = false + selectedServices = emptyList() + baseLocation = phoneLocation + return workerProfiles } /** * Creates the list of filter buttons. * - * Each button's action updates the filter state and calls [onProfilesUpdated] - * to update the displayed profiles. + * Each button's action updates the filter state and calls [onProfilesUpdated] to update the + * displayed profiles. */ fun SearchFiltersState.getFilterButtons( workerProfiles: List, @@ -136,60 +127,53 @@ fun SearchFiltersState.getFilterButtons( onShowLocationBottomSheet: () -> Unit ): List { - return listOf( - SearchFilterButtons( - onClick = { - val cleared = clearFilters(workerProfiles) - onProfilesUpdated(cleared) - }, - text = "Clear", - leadingIcon = Icons.Default.Clear - ), - SearchFilterButtons( - onClick = { onShowLocationBottomSheet() }, - text = "Location", - leadingIcon = Icons.Default.LocationSearching, - trailingIcon = Icons.Default.KeyboardArrowDown, - applied = locationFilterApplied - ), - SearchFilterButtons( - onClick = { onShowServicesBottomSheet() }, - text = "Service Type", - leadingIcon = Icons.Default.Handyman, - trailingIcon = Icons.Default.KeyboardArrowDown, - applied = servicesFilterApplied - ), - SearchFilterButtons( - onClick = { onShowAvailabilityBottomSheet() }, - text = "Availability", - leadingIcon = Icons.Default.CalendarMonth, - trailingIcon = Icons.Default.KeyboardArrowDown, - applied = availabilityFilterApplied - ), - SearchFilterButtons( - onClick = { - if (ratingFilterApplied) { - ratingFilterApplied = false - onProfilesUpdated(reapplyFilters(workerProfiles, searchViewModel)) - } else { - val rated = searchViewModel.sortWorkersByRating(filteredProfiles) - ratingFilterApplied = true - onProfilesUpdated(rated) - } - }, - text = "Highest Rating", - leadingIcon = Icons.Default.WorkspacePremium, - trailingIcon = if (ratingFilterApplied) Icons.Default.Clear else null, - applied = ratingFilterApplied - ), - SearchFilterButtons( - onClick = { onShowPriceRangeBottomSheet() }, - text = "Price Range", - leadingIcon = Icons.Default.MonetizationOn, - trailingIcon = Icons.Default.KeyboardArrowDown, - applied = priceFilterApplied - ) - ) + return listOf( + SearchFilterButtons( + onClick = { + val cleared = clearFilters(workerProfiles) + onProfilesUpdated(cleared) + }, + text = "Clear", + leadingIcon = Icons.Default.Clear), + SearchFilterButtons( + onClick = { onShowLocationBottomSheet() }, + text = "Location", + leadingIcon = Icons.Default.LocationSearching, + trailingIcon = Icons.Default.KeyboardArrowDown, + applied = locationFilterApplied), + SearchFilterButtons( + onClick = { onShowServicesBottomSheet() }, + text = "Service Type", + leadingIcon = Icons.Default.Handyman, + trailingIcon = Icons.Default.KeyboardArrowDown, + applied = servicesFilterApplied), + SearchFilterButtons( + onClick = { onShowAvailabilityBottomSheet() }, + text = "Availability", + leadingIcon = Icons.Default.CalendarMonth, + trailingIcon = Icons.Default.KeyboardArrowDown, + applied = availabilityFilterApplied), + SearchFilterButtons( + onClick = { + if (ratingFilterApplied) { + ratingFilterApplied = false + onProfilesUpdated(reapplyFilters(workerProfiles, searchViewModel)) + } else { + val rated = searchViewModel.sortWorkersByRating(filteredProfiles) + ratingFilterApplied = true + onProfilesUpdated(rated) + } + }, + text = "Highest Rating", + leadingIcon = Icons.Default.WorkspacePremium, + trailingIcon = if (ratingFilterApplied) Icons.Default.Clear else null, + applied = ratingFilterApplied), + SearchFilterButtons( + onClick = { onShowPriceRangeBottomSheet() }, + text = "Price Range", + leadingIcon = Icons.Default.MonetizationOn, + trailingIcon = Icons.Default.KeyboardArrowDown, + applied = priceFilterApplied)) } @Composable @@ -199,51 +183,46 @@ fun FilterRow( listOfButtons: List, modifier: Modifier = Modifier ) { - val screenHeight = 800.dp // These could be replaced with actual dimension calculations - val screenWidth = 400.dp - - IconButton( - onClick = { toggleFilterButtons() }, - modifier = modifier.testTag("tuneButton"), - colors = IconButtonDefaults.iconButtonColors( - containerColor = - if (showFilterButtons) colorScheme.primary else colorScheme.surface - ) - ) { + val screenHeight = 800.dp // These could be replaced with actual dimension calculations + val screenWidth = 400.dp + + IconButton( + onClick = { toggleFilterButtons() }, + modifier = modifier.testTag("tuneButton"), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = + if (showFilterButtons) colorScheme.primary else colorScheme.surface)) { Icon( imageVector = Icons.Default.Tune, contentDescription = "Filter", - tint = - if (showFilterButtons) colorScheme.onPrimary - else colorScheme.onBackground, + tint = if (showFilterButtons) colorScheme.onPrimary else colorScheme.onBackground, ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + AnimatedVisibility(visible = showFilterButtons) { + LazyRow(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + items(listOfButtons.size) { index -> + val button = listOfButtons[index] + QuickFixButton( + buttonText = button.text, + onClickAction = button.onClick, + buttonColor = if (button.applied) colorScheme.primary else colorScheme.surface, + textColor = if (button.applied) colorScheme.onPrimary else colorScheme.onBackground, + textStyle = poppinsTypography.labelSmall.copy(fontWeight = FontWeight.Medium), + height = screenHeight * 0.05f, + leadingIcon = button.leadingIcon, + trailingIcon = button.trailingIcon, + leadingIconTint = + if (button.applied) colorScheme.onPrimary else colorScheme.onBackground, + trailingIconTint = + if (button.applied) colorScheme.onPrimary else colorScheme.onBackground, + contentPadding = PaddingValues(vertical = 0.dp, horizontal = screenWidth * 0.02f), + modifier = Modifier.testTag("filter_button_${button.text}")) + Spacer(modifier = Modifier.width(screenHeight * 0.01f)) + } } - - Spacer(modifier = Modifier.width(10.dp)) - - AnimatedVisibility(visible = showFilterButtons) { - LazyRow(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - items(listOfButtons.size) { index -> - val button = listOfButtons[index] - QuickFixButton( - buttonText = button.text, - onClickAction = button.onClick, - buttonColor = if (button.applied) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface, - textColor = if (button.applied) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onBackground, - textStyle = poppinsTypography.labelSmall.copy(fontWeight = FontWeight.Medium), - height = screenHeight * 0.05f, - leadingIcon = button.leadingIcon, - trailingIcon = button.trailingIcon, - leadingIconTint = if (button.applied) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onBackground, - trailingIconTint = if (button.applied) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onBackground, - contentPadding = PaddingValues( - vertical = 0.dp, - horizontal = screenWidth * 0.02f - ), - modifier = Modifier.testTag("filter_button_${button.text}") - ) - Spacer(modifier = Modifier.width(screenHeight * 0.01f)) - } - } - } + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/search/SearchOnBoarding.kt index e7c9ed46..f90715bc 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/search/SearchOnBoarding.kt @@ -2,6 +2,7 @@ package com.arygm.quickfix.ui.search import QuickFixSlidingWindowWorker import android.util.Log +import android.widget.Toast import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -23,6 +24,8 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,14 +33,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.profile.UserProfile import com.arygm.quickfix.model.search.SearchViewModel +import com.arygm.quickfix.ui.elements.ChooseServiceTypeSheet +import com.arygm.quickfix.ui.elements.QuickFixAvailabilityBottomSheet import com.arygm.quickfix.ui.elements.QuickFixButton +import com.arygm.quickfix.ui.elements.QuickFixLocationFilterBottomSheet +import com.arygm.quickfix.ui.elements.QuickFixPriceRangeBottomSheet import com.arygm.quickfix.ui.elements.QuickFixTextFieldCustom import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.navigation.TopLevelDestinations @@ -53,167 +62,291 @@ fun SearchOnBoarding( accountViewModel: AccountViewModel, categoryViewModel: CategoryViewModel ) { - val profiles = searchViewModel.workerProfiles.collectAsState().value - val focusManager = LocalFocusManager.current - val categories = categoryViewModel.categories.collectAsState().value - Log.d("SearchOnBoarding", "Categories: $categories") - val itemCategories = remember { categories } - val expandedStates = remember { - mutableStateListOf(*BooleanArray(itemCategories.size) { false }.toTypedArray()) - } - val listState = rememberLazyListState() - - var searchQuery by remember { mutableStateOf("") } - var isWindowVisible by remember { mutableStateOf(false) } - - // Variables for WorkerSlidingWindowContent - // These will be set when a worker profile is selected - var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerCategory by remember { mutableStateOf("Exterior Painter") } - var workerAddress by remember { mutableStateOf("Ecublens, VD") } - var description by remember { mutableStateOf("Worker description goes here.") } - var includedServices by remember { mutableStateOf(listOf("Service 1", "Service 2")) } - var addonServices by remember { mutableStateOf(listOf("Add-on 1", "Add-on 2")) } - var workerRating by remember { mutableStateOf(4.5) } - var tags by remember { mutableStateOf(listOf("Tag1", "Tag2")) } - var reviews by remember { mutableStateOf(listOf("Review 1", "Review 2")) } - - BoxWithConstraints { - val widthRatio = maxWidth.value / 411f - val heightRatio = maxHeight.value / 860f - val sizeRatio = minOf(widthRatio, heightRatio) - val screenHeight = maxHeight - val screenWidth = maxWidth - - // Use Scaffold for the layout structure - Scaffold( - containerColor = colorScheme.background, - content = { padding -> - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(padding) - .padding(top = 40.dp * heightRatio) - .padding(horizontal = 10.dp * widthRatio), - horizontalAlignment = Alignment.CenterHorizontally - ) { + val context = LocalContext.current + val profiles = searchViewModel.workerProfiles.collectAsState().value + var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") + val focusManager = LocalFocusManager.current + val categories = categoryViewModel.categories.collectAsState().value + Log.d("SearchOnBoarding", "Categories: $categories") + val itemCategories = remember { categories } + val expandedStates = remember { + mutableStateListOf(*BooleanArray(itemCategories.size) { false }.toTypedArray()) + } + val listState = rememberLazyListState() + + var searchQuery by remember { mutableStateOf("") } + val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() + + // Filtering logic + val filterState = rememberSearchFiltersState() + var filteredWorkerProfiles by remember { mutableStateOf(profiles) } + + fun updateFilteredProfiles() { + filteredWorkerProfiles = filterState.reapplyFilters(profiles, searchViewModel) + } + + var showFilterButtons by remember { mutableStateOf(false) } + var showAvailabilityBottomSheet by remember { mutableStateOf(false) } + var showServicesBottomSheet by remember { mutableStateOf(false) } + var showPriceRangeBottomSheet by remember { mutableStateOf(false) } + var showLocationBottomSheet by remember { mutableStateOf(false) } + // Build filter buttons + val listOfButtons = + filterState.getFilterButtons( + workerProfiles = profiles, + filteredProfiles = filteredWorkerProfiles, + searchViewModel = searchViewModel, + onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, + onShowAvailabilityBottomSheet = { showAvailabilityBottomSheet = true }, + onShowServicesBottomSheet = { showServicesBottomSheet = true }, + onShowPriceRangeBottomSheet = { showPriceRangeBottomSheet = true }, + onShowLocationBottomSheet = { showLocationBottomSheet = true }, + ) + + var isWindowVisible by remember { mutableStateOf(false) } + + // Variables for WorkerSlidingWindowContent + var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } + var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } + var initialSaved by remember { mutableStateOf(false) } + var workerCategory by remember { mutableStateOf("Exterior Painter") } + var workerAddress by remember { mutableStateOf("Ecublens, VD") } + var description by remember { mutableStateOf("Worker description goes here.") } + var includedServices by remember { mutableStateOf(listOf("Service 1", "Service 2")) } + var addonServices by remember { mutableStateOf(listOf("Add-on 1", "Add-on 2")) } + var workerRating by remember { mutableDoubleStateOf(4.5) } + var tags by remember { mutableStateOf(listOf("Tag1", "Tag2")) } + var reviews by remember { mutableStateOf(listOf("Review 1", "Review 2")) } + + BoxWithConstraints { + val widthRatio = maxWidth.value / 411f + val heightRatio = maxHeight.value / 860f + val sizeRatio = minOf(widthRatio, heightRatio) + val screenHeight = maxHeight + val screenWidth = maxWidth + + Scaffold( + containerColor = colorScheme.background, + content = { padding -> + Column( + modifier = + Modifier.fillMaxWidth() + .padding(padding) + .padding(top = 40.dp * heightRatio) + .padding(horizontal = 10.dp * widthRatio), + horizontalAlignment = Alignment.CenterHorizontally) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp * heightRatio), + horizontalArrangement = Arrangement.Center) { + QuickFixTextFieldCustom( + modifier = Modifier.testTag("searchContent"), + showLeadingIcon = { true }, + leadingIcon = Icons.Outlined.Search, + showTrailingIcon = { searchQuery.isNotEmpty() }, + trailingIcon = { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear search query", + tint = colorScheme.onBackground, + ) + }, + placeHolderText = "Find your perfect fix with QuickFix", + value = searchQuery, + onValueChange = { + searchQuery = it + searchViewModel.updateSearchQuery(it) + if (it.isEmpty()) { + onSearchEmpty() + // When search is empty, we can reset filteredWorkerProfiles to + // original + filteredWorkerProfiles = profiles + } else { + onSearch() + // If needed, reapply filters here if filters are set + updateFilteredProfiles() + } + }, + shape = CircleShape, + textStyle = poppinsTypography.bodyMedium, + textColor = colorScheme.onBackground, + placeHolderColor = colorScheme.onBackground, + leadIconColor = colorScheme.onBackground, + widthField = 300.dp * widthRatio, + heightField = 40.dp * heightRatio, + moveContentHorizontal = 10.dp * widthRatio, + moveContentBottom = 0.dp, + moveContentTop = 0.dp, + sizeIconGroup = 30.dp * sizeRatio, + spaceBetweenLeadIconText = 0.dp, + onClick = true, + ) + Spacer(modifier = Modifier.width(10.dp * widthRatio)) + QuickFixButton( + buttonText = "Cancel", + textColor = colorScheme.onBackground, + buttonColor = colorScheme.background, + buttonOpacity = 1f, + textStyle = poppinsTypography.labelSmall, + onClickAction = { + navigationActionsRoot.navigateTo(TopLevelDestinations.HOME) + }, + contentPadding = PaddingValues(0.dp), + ) + } + if (searchQuery.isEmpty()) { + // Show Categories + CategoryContent( + navigationActions = navigationActions, + searchViewModel = searchViewModel, + listState = listState, + expandedStates = expandedStates, + itemCategories = itemCategories, + widthRatio = widthRatio, + heightRatio = heightRatio, + ) + } else { + // Show Profiles + // Insert filter buttons here (only when searchQuery is not empty) + Column { Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp * heightRatio), - horizontalArrangement = Arrangement.Center + modifier = + Modifier.fillMaxWidth() + .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) + .padding(horizontal = screenWidth * 0.02f) + .testTag("filter_buttons_row"), + verticalAlignment = Alignment.CenterVertically, ) { - QuickFixTextFieldCustom( - modifier = Modifier.testTag("searchContent"), - showLeadingIcon = { true }, - leadingIcon = Icons.Outlined.Search, - showTrailingIcon = { searchQuery.isNotEmpty() }, - trailingIcon = { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = "Clear search query", - tint = colorScheme.onBackground, - ) - }, - placeHolderText = "Find your perfect fix with QuickFix", - value = searchQuery, - onValueChange = { - searchQuery = it - searchViewModel.updateSearchQuery(it) - if (it.isEmpty()) { - onSearchEmpty() - } else { - onSearch() - } - }, - shape = CircleShape, - textStyle = poppinsTypography.bodyMedium, - textColor = colorScheme.onBackground, - placeHolderColor = colorScheme.onBackground, - leadIconColor = colorScheme.onBackground, - widthField = 300.dp * widthRatio, - heightField = 40.dp * heightRatio, - moveContentHorizontal = 10.dp * widthRatio, - moveContentBottom = 0.dp, - moveContentTop = 0.dp, - sizeIconGroup = 30.dp * sizeRatio, - spaceBetweenLeadIconText = 0.dp, - onClick = true, - ) - Spacer(modifier = Modifier.width(10.dp * widthRatio)) - QuickFixButton( - buttonText = "Cancel", - textColor = colorScheme.onBackground, - buttonColor = colorScheme.background, - buttonOpacity = 1f, - textStyle = poppinsTypography.labelSmall, - onClickAction = { - navigationActionsRoot.navigateTo(TopLevelDestinations.HOME) - }, - contentPadding = PaddingValues(0.dp), - ) - } - if (searchQuery.isEmpty()) { - // Show Categories - CategoryContent( - navigationActions = navigationActions, - searchViewModel = searchViewModel, - listState = listState, - expandedStates = expandedStates, - itemCategories = itemCategories, - widthRatio = widthRatio, - heightRatio = heightRatio, - ) - } else { - // Show Profiles - ProfileResults( - profiles = profiles, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - listState = listState, - heightRatio = heightRatio, - onBookClick = { selectedProfile -> - // Set up variables for WorkerSlidingWindowContent - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerCategory = selectedProfile.fieldOfWork - workerAddress = selectedProfile.location?.name ?: "Unknown" - description = selectedProfile.description - includedServices = selectedProfile.includedServices.map { it.name } - addonServices = selectedProfile.addOnServices.map { it.name } - workerRating = selectedProfile.rating - tags = selectedProfile.tags - reviews = selectedProfile.reviews.map { it.review } - isWindowVisible = true - }) + FilterRow( + showFilterButtons = showFilterButtons, + toggleFilterButtons = { showFilterButtons = !showFilterButtons }, + listOfButtons = listOfButtons, + modifier = Modifier.padding(bottom = screenHeight * 0.01f)) } + + ProfileResults( + profiles = filteredWorkerProfiles, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + listState = listState, + onBookClick = { selectedProfile -> + // Set up variables for WorkerSlidingWindowContent + bannerImage = R.drawable.moroccan_flag + profilePicture = R.drawable.placeholder_worker + initialSaved = false + workerCategory = selectedProfile.fieldOfWork + workerAddress = selectedProfile.location?.name ?: "Unknown" + description = selectedProfile.description + includedServices = selectedProfile.includedServices.map { it.name } + addonServices = selectedProfile.addOnServices.map { it.name } + workerRating = selectedProfile.rating + tags = selectedProfile.tags + reviews = selectedProfile.reviews.map { it.review } + isWindowVisible = true + }) + } } - }, - modifier = + } + }, + modifier = Modifier.pointerInput(Unit) { - detectTapGestures(onTap = { focusManager.clearFocus() }) + detectTapGestures(onTap = { focusManager.clearFocus() }) }) - QuickFixSlidingWindowWorker( - isVisible = isWindowVisible, - onDismiss = { isWindowVisible = false }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = initialSaved, - workerCategory = workerCategory, - workerAddress = workerAddress, - description = description, - includedServices = includedServices, - addonServices = addonServices, - workerRating = workerRating, - tags = tags, - reviews = reviews, - screenHeight = screenHeight, - screenWidth = screenWidth, - onContinueClick = { /* Handle continue */ }) + QuickFixAvailabilityBottomSheet( + showAvailabilityBottomSheet, + onDismissRequest = { showAvailabilityBottomSheet = false }, + onOkClick = { days, hour, minute -> + filterState.selectedDays = days + filterState.selectedHour = hour + filterState.selectedMinute = minute + filterState.availabilityFilterApplied = true + updateFilteredProfiles() + }, + onClearClick = { + filterState.availabilityFilterApplied = false + filterState.selectedDays = emptyList() + filterState.selectedHour = 0 + filterState.selectedMinute = 0 + updateFilteredProfiles() + }, + clearEnabled = filterState.availabilityFilterApplied) + + searchSubcategory?.let { + ChooseServiceTypeSheet( + showServicesBottomSheet, + it.tags, + selectedServices = filterState.selectedServices, + onApplyClick = { services -> + filterState.selectedServices = services + filterState.servicesFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showServicesBottomSheet = false }, + onClearClick = { + filterState.selectedServices = emptyList() + filterState.servicesFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.servicesFilterApplied) } + + QuickFixPriceRangeBottomSheet( + showPriceRangeBottomSheet, + onApplyClick = { start, end -> + filterState.selectedPriceStart = start + filterState.selectedPriceEnd = end + filterState.priceFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showPriceRangeBottomSheet = false }, + onClearClick = { + filterState.selectedPriceStart = 0 + filterState.selectedPriceEnd = 0 + filterState.priceFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.priceFilterApplied) + + QuickFixLocationFilterBottomSheet( + showLocationBottomSheet, + userProfile = userProfile, + phoneLocation = filterState.phoneLocation, + onApplyClick = { location, max -> + filterState.selectedLocation = location + if (location == com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } + filterState.baseLocation = location + filterState.maxDistance = max + filterState.locationFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showLocationBottomSheet = false }, + onClearClick = { + filterState.baseLocation = filterState.phoneLocation + filterState.selectedLocation = com.arygm.quickfix.model.locations.Location() + filterState.maxDistance = 0 + filterState.locationFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.locationFilterApplied) + + QuickFixSlidingWindowWorker( + isVisible = isWindowVisible, + onDismiss = { isWindowVisible = false }, + bannerImage = bannerImage, + profilePicture = profilePicture, + initialSaved = initialSaved, + workerCategory = workerCategory, + workerAddress = workerAddress, + description = description, + includedServices = includedServices, + addonServices = addonServices, + workerRating = workerRating, + tags = tags, + reviews = reviews, + screenHeight = screenHeight, + screenWidth = screenWidth, + onContinueClick = { /* Handle continue */}) + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/search/SearchWorkerResult.kt index 71f76cf8..2959fb8d 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/search/SearchWorkerResult.kt @@ -3,11 +3,9 @@ package com.arygm.quickfix.ui.search import QuickFixSlidingWindowWorker import android.util.Log import android.widget.Toast -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -29,6 +27,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -71,166 +71,159 @@ fun SearchWorkerResult( userProfileViewModel: ProfileViewModel, preferencesViewModel: PreferencesViewModel ) { - val context = LocalContext.current - val locationHelper = LocationHelper(context, MainActivity()) + val context = LocalContext.current + val locationHelper = LocationHelper(context, MainActivity()) - val filterState = rememberSearchFiltersState() + val filterState = rememberSearchFiltersState() - val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() - var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } + val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() + var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } - var showFilterButtons by remember { mutableStateOf(false) } - var showAvailabilityBottomSheet by remember { mutableStateOf(false) } - var showServicesBottomSheet by remember { mutableStateOf(false) } - var showPriceRangeBottomSheet by remember { mutableStateOf(false) } - var showLocationBottomSheet by remember { mutableStateOf(false) } + var showFilterButtons by remember { mutableStateOf(false) } + var showAvailabilityBottomSheet by remember { mutableStateOf(false) } + var showServicesBottomSheet by remember { mutableStateOf(false) } + var showPriceRangeBottomSheet by remember { mutableStateOf(false) } + var showLocationBottomSheet by remember { mutableStateOf(false) } - var isWindowVisible by remember { mutableStateOf(false) } - var saved by remember { mutableStateOf(false) } + var isWindowVisible by remember { mutableStateOf(false) } + var saved by remember { mutableStateOf(false) } - var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerCategory by remember { mutableStateOf("Exterior Painter") } - var workerAddress by remember { mutableStateOf("Ecublens, VD") } - var description by remember { mutableStateOf("Worker description goes here.") } - var includedServices by remember { mutableStateOf(listOf()) } - var addonServices by remember { mutableStateOf(listOf()) } - var workerRating by remember { mutableStateOf(4.5) } - var tags by remember { mutableStateOf(listOf()) } - var reviews by remember { mutableStateOf(listOf()) } + var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } + var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } + var initialSaved by remember { mutableStateOf(false) } + var workerCategory by remember { mutableStateOf("Exterior Painter") } + var workerAddress by remember { mutableStateOf("Ecublens, VD") } + var description by remember { mutableStateOf("Worker description goes here.") } + var includedServices by remember { mutableStateOf(listOf()) } + var addonServices by remember { mutableStateOf(listOf()) } + var workerRating by remember { mutableDoubleStateOf(4.5) } + var tags by remember { mutableStateOf(listOf()) } + var reviews by remember { mutableStateOf(listOf()) } - // User and location setup - var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") - var uid by remember { mutableStateOf("Loading...") } + // User and location setup + var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") + var uid by remember { mutableStateOf("Loading...") } - val searchQuery by searchViewModel.searchQuery.collectAsState() - val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() + val searchQuery by searchViewModel.searchQuery.collectAsState() + val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() - // Fetch user id and profile - LaunchedEffect(Unit) { - uid = loadUserId(preferencesViewModel) - userProfileViewModel.fetchUserProfile(uid) { profile -> - if (profile is UserProfile) { - userProfile = profile - } else { - Log.e("SearchWorkerResult", "Fetched a worker profile from a user profile repo.") - } - } + // Fetch user id and profile + LaunchedEffect(Unit) { + uid = loadUserId(preferencesViewModel) + userProfileViewModel.fetchUserProfile(uid) { profile -> + if (profile is UserProfile) { + userProfile = profile + } else { + Log.e("SearchWorkerResult", "Fetched a worker profile from a user profile repo.") + } } + } - // Location initialization - LaunchedEffect(Unit) { - if (locationHelper.checkPermissions()) { - locationHelper.getCurrentLocation { location -> - if (location != null) { - val userLoc = com.arygm.quickfix.model.locations.Location( - location.latitude, location.longitude, "Phone Location" - ) - filterState.phoneLocation = userLoc - filterState.baseLocation = userLoc - } else { - Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() - } - } + // Location initialization + LaunchedEffect(Unit) { + if (locationHelper.checkPermissions()) { + locationHelper.getCurrentLocation { location -> + if (location != null) { + val userLoc = + com.arygm.quickfix.model.locations.Location( + location.latitude, location.longitude, "Phone Location") + filterState.phoneLocation = userLoc + filterState.baseLocation = userLoc } else { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() } + } + } else { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() } + } - val listState = rememberLazyListState() + val listState = rememberLazyListState() - fun updateFilteredProfiles() { - filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) - } + fun updateFilteredProfiles() { + filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) + } - // Build the list of filter buttons through the filter state - val listOfButtons = filterState.getFilterButtons( - workerProfiles = workerProfiles, - filteredProfiles = filteredWorkerProfiles, - searchViewModel = searchViewModel, - onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, - onShowAvailabilityBottomSheet = { showAvailabilityBottomSheet = true }, - onShowServicesBottomSheet = { showServicesBottomSheet = true }, - onShowPriceRangeBottomSheet = { showPriceRangeBottomSheet = true }, - onShowLocationBottomSheet = { showLocationBottomSheet = true }, - ) + // Build the list of filter buttons through the filter state + val listOfButtons = + filterState.getFilterButtons( + workerProfiles = workerProfiles, + filteredProfiles = filteredWorkerProfiles, + searchViewModel = searchViewModel, + onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, + onShowAvailabilityBottomSheet = { showAvailabilityBottomSheet = true }, + onShowServicesBottomSheet = { showServicesBottomSheet = true }, + onShowPriceRangeBottomSheet = { showPriceRangeBottomSheet = true }, + onShowLocationBottomSheet = { showLocationBottomSheet = true }, + ) - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val screenHeight = maxHeight - val screenWidth = maxWidth + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenHeight = maxHeight + val screenWidth = maxWidth - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { - Text(text = "Search Results", style = MaterialTheme.typography.titleMedium) - }, - navigationIcon = { - IconButton(onClick = { navigationActions.goBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - actions = { - IconButton(onClick = { /* Handle search */ }) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = colorScheme.onBackground - ) - } - }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = colorScheme.background - ), - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues), - horizontalAlignment = Alignment.CenterHorizontally - ) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text(text = "Search Results", style = MaterialTheme.typography.titleMedium) + }, + navigationIcon = { + IconButton(onClick = { navigationActions.goBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { /* Handle search */}) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = colorScheme.onBackground) + } + }, + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = colorScheme.background), + ) + }) { paddingValues -> + Column( + modifier = Modifier.fillMaxWidth().padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top - ) { - Text( - text = searchQuery, - style = poppinsTypography.labelMedium, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - Text( - text = "This is a sample description for the $searchQuery result", - style = poppinsTypography.labelSmall, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - color = colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - } + verticalArrangement = Arrangement.Top) { + Text( + text = searchQuery, + style = poppinsTypography.labelMedium, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Text( + text = "This is a sample description for the $searchQuery result", + style = poppinsTypography.labelSmall, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + color = colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) - .padding(horizontal = screenWidth * 0.02f) - .testTag("filter_buttons_row"), + modifier = + Modifier.fillMaxWidth() + .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) + .padding(horizontal = screenWidth * 0.02f) + .testTag("filter_buttons_row"), verticalAlignment = Alignment.CenterVertically, ) { - FilterRow( - showFilterButtons = showFilterButtons, - toggleFilterButtons = { showFilterButtons = !showFilterButtons }, - listOfButtons = listOfButtons, - modifier = Modifier.padding(bottom = screenHeight * 0.01f) - ) + FilterRow( + showFilterButtons = showFilterButtons, + toggleFilterButtons = { showFilterButtons = !showFilterButtons }, + listOfButtons = listOfButtons, + modifier = Modifier.padding(bottom = screenHeight * 0.01f)) } ProfileResults( @@ -239,160 +232,155 @@ fun SearchWorkerResult( listState = listState, searchViewModel = searchViewModel, accountViewModel = accountViewModel, - heightRatio = 1f, onBookClick = { selectedProfile -> - // Mock data for demonstration, replace with actual data - val profile = WorkerProfile( - rating = 4.8, - fieldOfWork = "Exterior Painter", - description = "Worker description goes here.", - location = com.arygm.quickfix.model.locations.Location( - 12.0, 12.0, "Ecublens, VD" - ), - quickFixes = listOf("Painting", "Gardening"), - includedServices = listOf( - IncludedService("Painting"), - IncludedService("Gardening"), - ), - addOnServices = listOf( - AddOnService("Furniture Assembly"), - AddOnService("Window Cleaning"), - ), - reviews = ArrayDeque( - listOf( - Review("Bob", "nice work", 4.0f), - Review("Alice", "bad work", 3.5f), - ) - ), - profilePicture = "placeholder_worker", - price = 130.0, - displayName = "John Doe", - unavailability_list = emptyList(), - workingHours = Pair(LocalTime.now(), LocalTime.now()), - uid = "1234", - tags = listOf("Painter", "Gardener"), - ) + // Mock data for demonstration, replace with actual data + val profile = + WorkerProfile( + rating = 4.8, + fieldOfWork = "Exterior Painter", + description = "Worker description goes here.", + location = + com.arygm.quickfix.model.locations.Location( + 12.0, 12.0, "Ecublens, VD"), + quickFixes = listOf("Painting", "Gardening"), + includedServices = + listOf( + IncludedService("Painting"), + IncludedService("Gardening"), + ), + addOnServices = + listOf( + AddOnService("Furniture Assembly"), + AddOnService("Window Cleaning"), + ), + reviews = + ArrayDeque( + listOf( + Review("Bob", "nice work", 4.0f), + Review("Alice", "bad work", 3.5f), + )), + profilePicture = "placeholder_worker", + price = 130.0, + displayName = "John Doe", + unavailability_list = emptyList(), + workingHours = Pair(LocalTime.now(), LocalTime.now()), + uid = "1234", + tags = listOf("Painter", "Gardener"), + ) - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerCategory = profile.fieldOfWork - workerAddress = profile.location?.name ?: "Unknown" - description = profile.description - includedServices = profile.includedServices.map { it.name } - addonServices = profile.addOnServices.map { it.name } - workerRating = profile.rating - tags = profile.tags - reviews = profile.reviews.map { it.review } + bannerImage = R.drawable.moroccan_flag + profilePicture = R.drawable.placeholder_worker + initialSaved = false + workerCategory = profile.fieldOfWork + workerAddress = profile.location?.name ?: "Unknown" + description = profile.description + includedServices = profile.includedServices.map { it.name } + addonServices = profile.addOnServices.map { it.name } + workerRating = profile.rating + tags = profile.tags + reviews = profile.reviews.map { it.review } - isWindowVisible = true - } - ) - } + isWindowVisible = true + }) + } } - // Bottom sheets for filters - QuickFixAvailabilityBottomSheet( - showAvailabilityBottomSheet, - onDismissRequest = { showAvailabilityBottomSheet = false }, - onOkClick = { days, hour, minute -> - filterState.selectedDays = days - filterState.selectedHour = hour - filterState.selectedMinute = minute - filterState.availabilityFilterApplied = true - updateFilteredProfiles() - }, - onClearClick = { - filterState.availabilityFilterApplied = false - filterState.selectedDays = emptyList() - filterState.selectedHour = 0 - filterState.selectedMinute = 0 - updateFilteredProfiles() - }, - clearEnabled = filterState.availabilityFilterApplied - ) + // Bottom sheets for filters + QuickFixAvailabilityBottomSheet( + showAvailabilityBottomSheet, + onDismissRequest = { showAvailabilityBottomSheet = false }, + onOkClick = { days, hour, minute -> + filterState.selectedDays = days + filterState.selectedHour = hour + filterState.selectedMinute = minute + filterState.availabilityFilterApplied = true + updateFilteredProfiles() + }, + onClearClick = { + filterState.availabilityFilterApplied = false + filterState.selectedDays = emptyList() + filterState.selectedHour = 0 + filterState.selectedMinute = 0 + updateFilteredProfiles() + }, + clearEnabled = filterState.availabilityFilterApplied) - searchSubcategory?.let { - ChooseServiceTypeSheet( - showServicesBottomSheet, - it.tags, - selectedServices = filterState.selectedServices, - onApplyClick = { services -> - filterState.selectedServices = services - filterState.servicesFilterApplied = true - updateFilteredProfiles() - }, - onDismissRequest = { showServicesBottomSheet = false }, - onClearClick = { - filterState.selectedServices = emptyList() - filterState.servicesFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = filterState.servicesFilterApplied - ) - } + searchSubcategory?.let { + ChooseServiceTypeSheet( + showServicesBottomSheet, + it.tags, + selectedServices = filterState.selectedServices, + onApplyClick = { services -> + filterState.selectedServices = services + filterState.servicesFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showServicesBottomSheet = false }, + onClearClick = { + filterState.selectedServices = emptyList() + filterState.servicesFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.servicesFilterApplied) + } - QuickFixPriceRangeBottomSheet( - showPriceRangeBottomSheet, - onApplyClick = { start, end -> - filterState.selectedPriceStart = start - filterState.selectedPriceEnd = end - filterState.priceFilterApplied = true - updateFilteredProfiles() - }, - onDismissRequest = { showPriceRangeBottomSheet = false }, - onClearClick = { - filterState.selectedPriceStart = 0 - filterState.selectedPriceEnd = 0 - filterState.priceFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = filterState.priceFilterApplied - ) + QuickFixPriceRangeBottomSheet( + showPriceRangeBottomSheet, + onApplyClick = { start, end -> + filterState.selectedPriceStart = start + filterState.selectedPriceEnd = end + filterState.priceFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showPriceRangeBottomSheet = false }, + onClearClick = { + filterState.selectedPriceStart = 0 + filterState.selectedPriceEnd = 0 + filterState.priceFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.priceFilterApplied) - QuickFixLocationFilterBottomSheet( - showLocationBottomSheet, - userProfile = userProfile, - phoneLocation = filterState.phoneLocation, - onApplyClick = { location, max -> - filterState.selectedLocation = location - if (location == com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT) - .show() - } - filterState.baseLocation = location - filterState.maxDistance = max - filterState.locationFilterApplied = true - updateFilteredProfiles() - }, - onDismissRequest = { showLocationBottomSheet = false }, - onClearClick = { - filterState.baseLocation = filterState.phoneLocation - filterState.selectedLocation = com.arygm.quickfix.model.locations.Location() - filterState.maxDistance = 0 - filterState.locationFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = filterState.locationFilterApplied - ) + QuickFixLocationFilterBottomSheet( + showLocationBottomSheet, + userProfile = userProfile, + phoneLocation = filterState.phoneLocation, + onApplyClick = { location, max -> + filterState.selectedLocation = location + if (location == com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } + filterState.baseLocation = location + filterState.maxDistance = max + filterState.locationFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showLocationBottomSheet = false }, + onClearClick = { + filterState.baseLocation = filterState.phoneLocation + filterState.selectedLocation = com.arygm.quickfix.model.locations.Location() + filterState.maxDistance = 0 + filterState.locationFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.locationFilterApplied) - QuickFixSlidingWindowWorker( - isVisible = isWindowVisible, - onDismiss = { isWindowVisible = false }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = saved, - workerCategory = workerCategory, - workerAddress = workerAddress, - description = description, - includedServices = includedServices, - addonServices = addonServices, - workerRating = workerRating, - tags = tags, - reviews = reviews, - screenHeight = maxHeight, - screenWidth = maxWidth, - onContinueClick = {} - ) - } + QuickFixSlidingWindowWorker( + isVisible = isWindowVisible, + onDismiss = { isWindowVisible = false }, + bannerImage = bannerImage, + profilePicture = profilePicture, + initialSaved = saved, + workerCategory = workerCategory, + workerAddress = workerAddress, + description = description, + includedServices = includedServices, + addonServices = addonServices, + workerRating = workerRating, + tags = tags, + reviews = reviews, + screenHeight = maxHeight, + screenWidth = maxWidth, + onContinueClick = {}) + } } From 897ce03d16b9cc1f6e2d33b532c61e60ea4ac70b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Thu, 12 Dec 2024 15:31:20 +0100 Subject: [PATCH 03/15] Feat: all the UI implemented in the QuickFixFinder.kt screen --- .../userModeUI/search/ProfileResults.kt | 4 - .../userModeUI/search/QuickFixFinder.kt | 228 +++++++++++++----- .../search/QuickFixSlidingWindowWorker.kt | 2 - .../userModeUI/search/SearchOnBoarding.kt | 32 +-- 4 files changed, 171 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt index cb65ea4e..70b1cc31 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt @@ -1,9 +1,7 @@ package com.arygm.quickfix.ui.search import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -83,8 +81,6 @@ fun ProfileResults( distance = distance, onBookClick = { onBookClick(profile) }) } - - Spacer(modifier = Modifier.height(0.dp)) } } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt index 5b521152..fa183c15 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt @@ -1,7 +1,9 @@ package com.arygm.quickfix.ui.search +import QuickFixSlidingWindowWorker import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -21,6 +23,8 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -32,13 +36,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.account.LoggedInAccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.ProfileViewModel +import com.arygm.quickfix.model.profile.WorkerProfile +import com.arygm.quickfix.model.profile.dataFields.AddOnService +import com.arygm.quickfix.model.profile.dataFields.IncludedService +import com.arygm.quickfix.model.profile.dataFields.Review import com.arygm.quickfix.model.search.AnnouncementViewModel import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.navigation.NavigationActions +import java.time.LocalTime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -57,78 +68,161 @@ fun QuickFixFinderScreen( viewModel(factory = AnnouncementViewModel.Factory), categoryViewModel: CategoryViewModel = viewModel(factory = CategoryViewModel.Factory) ) { + var isWindowVisible by remember { mutableStateOf(false) } + var pager by remember { mutableStateOf(true) } - Scaffold( - containerColor = colorScheme.background, - topBar = { - TopAppBar( - title = { - Text( - text = "Quickfix", - color = colorScheme.primary, - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier.testTag("QuickFixFinderTopBarTitle")) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.background), - modifier = Modifier.testTag("QuickFixFinderTopBar")) - }, - content = { padding -> - Column( - modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally) { - val pagerState = rememberPagerState(pageCount = { 2 }) - val coroutineScope = rememberCoroutineScope() + var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } + var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } + var initialSaved by remember { mutableStateOf(false) } + var workerCategory by remember { mutableStateOf("Exterior Painter") } + var workerAddress by remember { mutableStateOf("Ecublens, VD") } + var description by remember { mutableStateOf("Worker description goes here.") } + var includedServices by remember { mutableStateOf(listOf()) } + var addonServices by remember { mutableStateOf(listOf()) } + var workerRating by remember { mutableDoubleStateOf(4.5) } + var tags by remember { mutableStateOf(listOf()) } + var reviews by remember { mutableStateOf(listOf()) } - if (pager) { - Surface( - color = colorScheme.surface, - shape = RoundedCornerShape(20.dp), - modifier = - Modifier.padding(horizontal = 40.dp).clip(RoundedCornerShape(20.dp))) { - TabRow( - selectedTabIndex = pagerState.currentPage, - containerColor = Color.Transparent, - divider = {}, - indicator = {}, - modifier = - Modifier.padding(horizontal = 1.dp, vertical = 1.dp) - .align(Alignment.CenterHorizontally) - .testTag("quickFixSearchTabRow")) { - QuickFixScreenTab(pagerState, coroutineScope, 0, "Search") - QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce") - } - } - } - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - modifier = Modifier.testTag("quickFixSearchPager")) { page -> - when (page) { - 0 -> { - SearchOnBoarding( - onSearch = { pager = false }, - onSearchEmpty = { pager = true }, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenHeight = maxHeight + val screenWidth = maxWidth + + Scaffold( + containerColor = colorScheme.background, + topBar = { + TopAppBar( + title = { + Text( + text = "Quickfix", + color = colorScheme.primary, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.testTag("QuickFixFinderTopBarTitle")) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.background), + modifier = Modifier.testTag("QuickFixFinderTopBar")) + }, + content = { padding -> + Column( + modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { + val pagerState = rememberPagerState(pageCount = { 2 }) + val coroutineScope = rememberCoroutineScope() + + if (pager) { + Surface( + color = colorScheme.surface, + shape = RoundedCornerShape(20.dp), + modifier = + Modifier.padding(horizontal = 40.dp).clip(RoundedCornerShape(20.dp))) { + TabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = Color.Transparent, + divider = {}, + indicator = {}, + modifier = + Modifier.padding(horizontal = 1.dp, vertical = 1.dp) + .align(Alignment.CenterHorizontally) + .testTag("quickFixSearchTabRow")) { + QuickFixScreenTab(pagerState, coroutineScope, 0, "Search") + QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce") + } } - 1 -> { - AnnouncementScreen( - announcementViewModel, - loggedInAccountViewModel, - profileViewModel, - accountViewModel, - navigationActions, - isUser) + } + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.testTag("quickFixSearchPager")) { page -> + when (page) { + 0 -> { + SearchOnBoarding( + onSearch = { pager = false }, + onSearchEmpty = { pager = true }, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { profile_ -> + val profile = + WorkerProfile( + rating = 4.8, + fieldOfWork = "Exterior Painter", + description = "Worker description goes here.", + location = Location(12.0, 12.0, "Ecublens, VD"), + quickFixes = listOf("Painting", "Gardening"), + includedServices = + listOf( + IncludedService("Painting"), + IncludedService("Gardening"), + ), + addOnServices = + listOf( + AddOnService("Furniture Assembly"), + AddOnService("Window Cleaning"), + ), + reviews = + ArrayDeque( + listOf( + Review("Bob", "nice work", 4.0), + Review("Alice", "bad work", 3.5), + )), + profilePicture = "placeholder_worker", + price = 130.0, + displayName = "John Doe", + unavailability_list = emptyList(), + workingHours = Pair(LocalTime.now(), LocalTime.now()), + uid = "1234", + tags = listOf("Painter", "Gardener"), + ) + + bannerImage = R.drawable.moroccan_flag + profilePicture = R.drawable.placeholder_worker + initialSaved = false + workerCategory = profile.fieldOfWork + workerAddress = profile.location?.name ?: "Unknown" + description = profile.description + includedServices = profile.includedServices.map { it.name } + addonServices = profile.addOnServices.map { it.name } + workerRating = profile.rating + tags = profile.tags + reviews = profile.reviews.map { it.review } + + isWindowVisible = true + }) + } + 1 -> { + AnnouncementScreen( + announcementViewModel, + loggedInAccountViewModel, + profileViewModel, + accountViewModel, + navigationActions, + isUser) + } + else -> Text("Should never happen !") } - else -> Text("Should never happen !") } - } - } - }) + } + }) + QuickFixSlidingWindowWorker( + isVisible = isWindowVisible, + onDismiss = { isWindowVisible = false }, + bannerImage = bannerImage, + profilePicture = profilePicture, + initialSaved = initialSaved, + workerCategory = workerCategory, + workerAddress = workerAddress, + description = description, + includedServices = includedServices, + addonServices = addonServices, + workerRating = workerRating, + tags = tags, + reviews = reviews, + screenHeight = screenHeight, + screenWidth = screenWidth, + onContinueClick = { /* Handle continue */}) + } } @Composable diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt index acad7a23..c77ef232 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt @@ -1,5 +1,3 @@ -// WorkerSlidingWindowContent.kt - import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index df33f4bd..7ec817ba 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -41,6 +41,7 @@ import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel import com.arygm.quickfix.model.profile.UserProfile +import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.elements.ChooseServiceTypeSheet import com.arygm.quickfix.ui.elements.QuickFixAvailabilityBottomSheet @@ -60,10 +61,11 @@ fun SearchOnBoarding( navigationActionsRoot: NavigationActions, searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, - categoryViewModel: CategoryViewModel + categoryViewModel: CategoryViewModel, + onProfileClick: (WorkerProfile) -> Unit ) { val context = LocalContext.current - val profiles = searchViewModel.workerProfiles.collectAsState().value + val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") val focusManager = LocalFocusManager.current val categories = categoryViewModel.categories.collectAsState().value @@ -79,10 +81,10 @@ fun SearchOnBoarding( // Filtering logic val filterState = rememberSearchFiltersState() - var filteredWorkerProfiles by remember { mutableStateOf(profiles) } + var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } fun updateFilteredProfiles() { - filteredWorkerProfiles = filterState.reapplyFilters(profiles, searchViewModel) + filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) } var showFilterButtons by remember { mutableStateOf(false) } @@ -93,7 +95,7 @@ fun SearchOnBoarding( // Build filter buttons val listOfButtons = filterState.getFilterButtons( - workerProfiles = profiles, + workerProfiles = workerProfiles, filteredProfiles = filteredWorkerProfiles, searchViewModel = searchViewModel, onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, @@ -136,7 +138,7 @@ fun SearchOnBoarding( .padding(horizontal = 10.dp * widthRatio), horizontalAlignment = Alignment.CenterHorizontally) { Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp * heightRatio), + modifier = Modifier.fillMaxWidth().padding(bottom = 0.dp * heightRatio), horizontalArrangement = Arrangement.Center) { QuickFixTextFieldCustom( modifier = Modifier.testTag("searchContent"), @@ -159,7 +161,7 @@ fun SearchOnBoarding( onSearchEmpty() // When search is empty, we can reset filteredWorkerProfiles to // original - filteredWorkerProfiles = profiles + filteredWorkerProfiles = workerProfiles } else { onSearch() // If needed, reapply filters here if filters are set @@ -228,21 +230,7 @@ fun SearchOnBoarding( searchViewModel = searchViewModel, accountViewModel = accountViewModel, listState = listState, - onBookClick = { selectedProfile -> - // Set up variables for WorkerSlidingWindowContent - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerCategory = selectedProfile.fieldOfWork - workerAddress = selectedProfile.location?.name ?: "Unknown" - description = selectedProfile.description - includedServices = selectedProfile.includedServices.map { it.name } - addonServices = selectedProfile.addOnServices.map { it.name } - workerRating = selectedProfile.rating - tags = selectedProfile.tags - reviews = selectedProfile.reviews.map { it.review } - isWindowVisible = true - }) + onBookClick = { selectedProfile -> onProfileClick(selectedProfile) }) } } } From 2272ac7d1ba3f2d6dff3da7bec5cd381ea860775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Thu, 12 Dec 2024 15:51:19 +0100 Subject: [PATCH 04/15] Style: package directives updated in search package and cleanup Cleanup consists in removing not needed code in SearchOnBoarding.kt --- .../AnnouncementUserNoModeScreenTest.kt | 1 + .../quickfix/ui/search/ProfileResultsTest.kt | 1 + .../QuickFixFinderUserNoModeScreenTest.kt | 1 + .../search/QuickFixSlidingWindowWorkerTest.kt | 2 +- .../ui/search/SearchCategoryButtonTest.kt | 1 + .../ui/search/SearchOnBoardingTest.kt | 1 + .../search/SearchWorkerProfileResultTest.kt | 1 + .../userModeUI/UserModeNavGraph.kt | 2 +- .../userModeUI/search/Announcement.kt | 30 ++++++++++---- .../userModeUI/search/CategoryContents.kt | 3 +- .../search/ExpandableCategoryItem.kt | 2 +- .../userModeUI/search/ProfileResults.kt | 2 +- .../userModeUI/search/QuickFixFinder.kt | 5 +-- .../search/QuickFixSlidingWindowWorker.kt | 2 + .../userModeUI/search/SearchFilters.kt | 2 +- .../userModeUI/search/SearchOnBoarding.kt | 39 +------------------ .../search/SearchWorkerProfileResult.kt | 2 +- .../userModeUI/search/SearchWorkerResult.kt | 6 --- 18 files changed, 41 insertions(+), 62 deletions(-) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementUserNoModeScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementUserNoModeScreenTest.kt index bb8052f4..0be47234 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementUserNoModeScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementUserNoModeScreenTest.kt @@ -13,6 +13,7 @@ import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.search.AnnouncementRepository import com.arygm.quickfix.model.search.AnnouncementViewModel import com.arygm.quickfix.ui.navigation.NavigationActions +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.AnnouncementScreen import com.arygm.quickfix.ui.userModeUI.navigation.UserScreen import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt index a3599ef7..d09ad856 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt @@ -13,6 +13,7 @@ import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.navigation.NavigationActions +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.ProfileResults import com.google.firebase.Timestamp import io.mockk.every import io.mockk.invoke diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt index 030a1c02..95849332 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt @@ -11,6 +11,7 @@ import com.arygm.quickfix.model.category.CategoryRepositoryFirestore import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.navigation.NavigationActions +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.QuickFixFinderScreen import com.arygm.quickfix.ui.userModeUI.navigation.UserRoute import com.arygm.quickfix.ui.userModeUI.navigation.UserScreen import com.arygm.quickfix.ui.userModeUI.navigation.UserTopLevelDestinations diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt index 9b17151b..0a037583 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt @@ -1,12 +1,12 @@ package com.arygm.quickfix.ui.search -import QuickFixSlidingWindowWorker import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.unit.dp import com.arygm.quickfix.R +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.QuickFixSlidingWindowWorker import org.junit.Rule import org.junit.Test diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchCategoryButtonTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchCategoryButtonTest.kt index e1585a08..afd70eb7 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchCategoryButtonTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchCategoryButtonTest.kt @@ -13,6 +13,7 @@ import com.arygm.quickfix.model.category.Subcategory import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ressources.C import com.arygm.quickfix.ui.navigation.NavigationActions +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.ExpandableCategoryItem import org.junit.Rule import org.junit.Test import org.mockito.Mockito.mock diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt index fc01f7aa..2f625364 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt @@ -19,6 +19,7 @@ import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ressources.C import com.arygm.quickfix.ui.navigation.NavigationActions +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchOnBoarding import io.mockk.mockk import org.junit.Before import org.junit.Rule diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerProfileResultTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerProfileResultTest.kt index ba8d338a..8dae3a00 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerProfileResultTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerProfileResultTest.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.arygm.quickfix.R +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchWorkerProfileResult import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt index 70fd1c3e..8d956b5e 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt @@ -29,7 +29,7 @@ import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.profile.AccountConfigurationScreen import com.arygm.quickfix.ui.profile.ProfileScreen import com.arygm.quickfix.ui.profile.becomeWorker.BusinessScreen -import com.arygm.quickfix.ui.search.QuickFixFinderScreen +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.QuickFixFinderScreen import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchWorkerResult import com.arygm.quickfix.ui.userModeUI.navigation.UserRoute import com.arygm.quickfix.ui.userModeUI.navigation.UserScreen diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt index 88568e45..c57362e8 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt @@ -1,11 +1,10 @@ -package com.arygm.quickfix.ui.search +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -13,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -25,13 +25,27 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.* +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color @@ -39,7 +53,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.* +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex @@ -281,14 +295,14 @@ fun AnnouncementScreen( text = "Location *", modifier = Modifier.testTag("locationText").padding(start = 3.dp), style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, + color = colorScheme.onBackground, textAlign = TextAlign.Start) Box( modifier = Modifier.testTag("locationInput") .shadow(elevation = 2.dp, shape = RoundedCornerShape(12.dp), clip = false) .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) + .background(colorScheme.surface) .border(1.dp, Color.Transparent, RoundedCornerShape(12.dp)) .width(360.dp) .height(42.dp) diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/CategoryContents.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/CategoryContents.kt index c252a0f2..659c358a 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/CategoryContents.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/CategoryContents.kt @@ -1,4 +1,4 @@ -package com.arygm.quickfix.ui.search +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -18,6 +18,7 @@ import com.arygm.quickfix.model.category.Category import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.ExpandableCategoryItem @Composable fun CategoryContent( diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt index a74e751b..df1a978b 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt @@ -1,4 +1,4 @@ -package com.arygm.quickfix.ui.search +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt index 70b1cc31..e90bdc9e 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt @@ -1,4 +1,4 @@ -package com.arygm.quickfix.ui.search +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt index fa183c15..804bd423 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt @@ -1,6 +1,5 @@ -package com.arygm.quickfix.ui.search +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search -import QuickFixSlidingWindowWorker import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -143,7 +142,7 @@ fun QuickFixFinderScreen( searchViewModel, accountViewModel, categoryViewModel, - onProfileClick = { profile_ -> + onProfileClick = { profiles -> val profile = WorkerProfile( rating = 4.8, diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt index c77ef232..b460ec6d 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt @@ -1,3 +1,5 @@ +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search + import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt index 8ed4acab..ba078753 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt @@ -1,4 +1,4 @@ -package com.arygm.quickfix.ui.search +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.PaddingValues diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index 7ec817ba..cf14ff8c 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -1,6 +1,5 @@ -package com.arygm.quickfix.ui.search +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search -import QuickFixSlidingWindowWorker import android.util.Log import android.widget.Toast import androidx.compose.foundation.gestures.detectTapGestures @@ -24,8 +23,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,7 +34,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel import com.arygm.quickfix.model.profile.UserProfile @@ -105,21 +101,6 @@ fun SearchOnBoarding( onShowLocationBottomSheet = { showLocationBottomSheet = true }, ) - var isWindowVisible by remember { mutableStateOf(false) } - - // Variables for WorkerSlidingWindowContent - var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerCategory by remember { mutableStateOf("Exterior Painter") } - var workerAddress by remember { mutableStateOf("Ecublens, VD") } - var description by remember { mutableStateOf("Worker description goes here.") } - var includedServices by remember { mutableStateOf(listOf("Service 1", "Service 2")) } - var addonServices by remember { mutableStateOf(listOf("Add-on 1", "Add-on 2")) } - var workerRating by remember { mutableDoubleStateOf(4.5) } - var tags by remember { mutableStateOf(listOf("Tag1", "Tag2")) } - var reviews by remember { mutableStateOf(listOf("Review 1", "Review 2")) } - BoxWithConstraints { val widthRatio = maxWidth.value / 411f val heightRatio = maxHeight.value / 860f @@ -318,23 +299,5 @@ fun SearchOnBoarding( updateFilteredProfiles() }, clearEnabled = filterState.locationFilterApplied) - - QuickFixSlidingWindowWorker( - isVisible = isWindowVisible, - onDismiss = { isWindowVisible = false }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = initialSaved, - workerCategory = workerCategory, - workerAddress = workerAddress, - description = description, - includedServices = includedServices, - addonServices = addonServices, - workerRating = workerRating, - tags = tags, - reviews = reviews, - screenHeight = screenHeight, - screenWidth = screenWidth, - onContinueClick = { /* Handle continue */}) } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerProfileResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerProfileResult.kt index fcffaab6..8faca2fd 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerProfileResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerProfileResult.kt @@ -1,4 +1,4 @@ -package com.arygm.quickfix.ui.search +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search import android.annotation.SuppressLint import androidx.compose.foundation.Image diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index 4da783b3..c921a9d6 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -1,6 +1,5 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search -import QuickFixSlidingWindowWorker import android.util.Log import android.widget.Toast import androidx.compose.foundation.layout.Arrangement @@ -56,11 +55,6 @@ import com.arygm.quickfix.ui.elements.QuickFixAvailabilityBottomSheet import com.arygm.quickfix.ui.elements.QuickFixLocationFilterBottomSheet import com.arygm.quickfix.ui.elements.QuickFixPriceRangeBottomSheet import com.arygm.quickfix.ui.navigation.NavigationActions -import com.arygm.quickfix.ui.search.FilterRow -import com.arygm.quickfix.ui.search.ProfileResults -import com.arygm.quickfix.ui.search.getFilterButtons -import com.arygm.quickfix.ui.search.reapplyFilters -import com.arygm.quickfix.ui.search.rememberSearchFiltersState import com.arygm.quickfix.ui.theme.poppinsTypography import com.arygm.quickfix.utils.LocationHelper import com.arygm.quickfix.utils.loadUserId From 0c57667c4cc030e68e5e80d26cbe286aa7c85eff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Fri, 13 Dec 2024 23:10:39 +0100 Subject: [PATCH 05/15] Fix: adapt tests to work with refactored SearchWorkerResult.kt --- .../quickfix/ui/search/ProfileResultsTest.kt | 1 - .../ui/search/SearchOnBoardingTest.kt | 30 +++-- .../ui/search/SearchWorkerResultScreenTest.kt | 114 +++++++++--------- .../userModeUI/search/CategoryContents.kt | 1 - .../search/QuickFixSlidingWindowWorker.kt | 102 +++++++--------- .../userModeUI/search/SearchFilters.kt | 45 +++---- .../userModeUI/search/SearchWorkerResult.kt | 3 +- 7 files changed, 147 insertions(+), 149 deletions(-) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt index d09ad856..c60ae005 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt @@ -100,7 +100,6 @@ class ProfileResultsTest { listState = rememberLazyListState(), searchViewModel = searchViewModel, accountViewModel = accountViewModel, - heightRatio = 1.0f, onBookClick = { _ -> }) } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt index 2f625364..1cae00a9 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt @@ -53,11 +53,15 @@ class SearchOnBoardingTest { fun searchOnBoarding_displaysSearchInput() { composeTestRule.setContent { SearchOnBoarding( - navigationActions = navigationActions, + onSearch = {}, + onSearchEmpty = {}, + navigationActions, navigationActionsRoot, searchViewModel, accountViewModel, - categoryViewModel) + categoryViewModel, + onProfileClick = { _ -> }, + ) } // Check that the search input field is displayed @@ -72,11 +76,15 @@ class SearchOnBoardingTest { fun searchOnBoarding_clearsTextOnTrailingIconClick() { composeTestRule.setContent { SearchOnBoarding( - navigationActions = navigationActions, + onSearch = {}, + onSearchEmpty = {}, + navigationActions, navigationActionsRoot, searchViewModel, accountViewModel, - categoryViewModel) + categoryViewModel, + onProfileClick = { _ -> }, + ) } // Input text into the search field @@ -100,11 +108,15 @@ class SearchOnBoardingTest { fun searchOnBoarding_switchesFromCategoriesToProfiles() { composeTestRule.setContent { SearchOnBoarding( - navigationActions = navigationActions, - navigationActionsRoot = navigationActionsRoot, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - categoryViewModel = categoryViewModel) + onSearch = {}, + onSearchEmpty = {}, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) } // Verify initial state (Categories are displayed) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt index 513e3817..f0b09aa9 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.filter -import androidx.compose.ui.test.hasAnyChild import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasParent import androidx.compose.ui.test.hasSetTextAction @@ -20,9 +19,11 @@ import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.printToLog import androidx.datastore.preferences.core.Preferences import androidx.test.ext.junit.runners.AndroidJUnit4 import com.arygm.quickfix.model.account.Account @@ -197,7 +198,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("tuneButton").performClick() // Verify that the LazyRow for filter buttons is visible - val filterButtonsRow = composeTestRule.onNodeWithTag("lazy_filter_row") + val filterButtonsRow = composeTestRule.onNodeWithTag("filter_buttons_row") filterButtonsRow.assertExists().assertIsDisplayed() // Define the expected button texts @@ -355,14 +356,14 @@ class SearchWorkerResultScreenTest { .onNodeWithTag("sliding_window_worker_category") .assertExists() .assertIsDisplayed() - .assertTextContains("Carpentry") // Replace with expected category + .assertTextContains("Exterior Painter") // Replace with expected category // Verify the worker address is displayed composeTestRule .onNodeWithTag("sliding_window_worker_address") .assertExists() .assertIsDisplayed() - .assertTextContains("New York") // Replace with expected address + .assertTextContains("Ecublens, VD") // Replace with expected address } @Test @@ -387,9 +388,8 @@ class SearchWorkerResultScreenTest { .onNodeWithTag("sliding_window_included_services_column") .assertExists() .assertIsDisplayed() - // Check for each included service - val includedServices = listOf("Basic Consultation", "Service Inspection") + val includedServices = listOf("Painting") includedServices.forEach { service -> composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() @@ -420,7 +420,7 @@ class SearchWorkerResultScreenTest { .assertIsDisplayed() // Check for each add-on service - val addOnServices = listOf("Express Delivery", "Premium Materials") + val addOnServices = listOf("Window Cleaning", "Furniture Assembly") addOnServices.forEach { service -> composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() @@ -481,8 +481,9 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("sliding_window_tags_flow_row").assertExists().assertIsDisplayed() // Check for each tag - val tags = listOf("Reliable", "Experienced", "Professional") + val tags = listOf("Painter", "Gardener") + composeTestRule.onRoot().printToLog("root") tags.forEach { tag -> composeTestRule.onNodeWithText(tag).assertExists().assertIsDisplayed() } } @@ -583,7 +584,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(3) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) // Simulate clicking the "Availability" filter button composeTestRule.onNodeWithText("Availability").performClick() @@ -670,7 +671,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(3) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) // Simulate clicking the "Availability" filter button composeTestRule.onNodeWithText("Availability").performClick() @@ -757,7 +758,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(3) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) // Simulate clicking the "Availability" filter button composeTestRule.onNodeWithText("Availability").performClick() @@ -830,7 +831,7 @@ class SearchWorkerResultScreenTest { } composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) // Click on the "Service Type" filter button composeTestRule.onNodeWithText("Service Type").performClick() @@ -893,7 +894,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("tuneButton").performClick() // Scroll to the "Highest Rating" button in the LazyRow - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(3) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) // Click on the "Highest Rating" filter button composeTestRule.onNodeWithText("Highest Rating").performClick() @@ -907,7 +908,7 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(sortedWorkers.size) sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasAnyChild(hasText("${worker.price}", substring = true))) + workerNodes[index].assert(hasText("${worker.price}", substring = true)) } } @@ -954,7 +955,7 @@ class SearchWorkerResultScreenTest { } composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) // Apply Service Type filter composeTestRule.onNodeWithText("Service Type").performClick() composeTestRule.waitForIdle() @@ -963,7 +964,7 @@ class SearchWorkerResultScreenTest { composeTestRule.waitForIdle() // Scroll to the "Highest Rating" button in the LazyRow - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(3) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) // Apply Highest Rating filter composeTestRule.onNodeWithText("Highest Rating").performClick() @@ -977,7 +978,7 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(filteredWorkers.size) filteredWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasAnyChild(hasText("${worker.price}", substring = true))) + workerNodes[index].assert(hasText("${worker.price}", substring = true)) } } @@ -1018,7 +1019,7 @@ class SearchWorkerResultScreenTest { } composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) // Apply Service Type filter for a tag that doesn't exist composeTestRule.onNodeWithText("Service Type").performClick() @@ -1040,7 +1041,7 @@ class SearchWorkerResultScreenTest { } composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(4) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) // Click on the "Price Range" filter button composeTestRule.onNodeWithText("Price Range").performClick() @@ -1084,7 +1085,7 @@ class SearchWorkerResultScreenTest { } composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(4) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) // Click on the "Price Range" filter button composeTestRule.onNodeWithText("Price Range").performClick() @@ -1102,7 +1103,7 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(sortedWorkers.size) sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasAnyChild(hasText("${worker.price}", substring = true))) + workerNodes[index].assert(hasText("${worker.price}", substring = true)) } } @@ -1139,7 +1140,7 @@ class SearchWorkerResultScreenTest { } composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(4) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) // Click on the "Price Range" filter button composeTestRule.onNodeWithText("Price Range").performClick() @@ -1157,7 +1158,7 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(sortedWorkers.size) sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasAnyChild(hasText("${worker.price}", substring = true))) + workerNodes[index].assert(hasText("${worker.price}", substring = true)) } } @@ -1168,12 +1169,12 @@ class SearchWorkerResultScreenTest { listOf( WorkerProfile( uid = "worker1", - location = com.arygm.quickfix.model.locations.Location(40.0, -74.0, "Home"), + location = Location(40.0, -74.0, "Home"), fieldOfWork = "Painter", rating = 4.5), WorkerProfile( uid = "worker2", - location = com.arygm.quickfix.model.locations.Location(45.0, -75.0, "Far"), + location = Location(45.0, -75.0, "Far"), fieldOfWork = "Electrician", rating = 4.0)) @@ -1192,7 +1193,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("tuneButton").performClick() // Scroll to the "Location" button in the LazyRow if needed - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) // Open the Location filter bottom sheet composeTestRule.onNodeWithText("Location").performClick() @@ -1202,7 +1203,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() // Select "Home" location - composeTestRule.onNodeWithText("Home").performClick() + composeTestRule.onNodeWithTag("locationOptionRow1").performClick() // Click Apply composeTestRule.onNodeWithTag("applyButton").performClick() @@ -1212,7 +1213,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) // Open Location filter again to clear - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) composeTestRule.onNodeWithText("Location").performClick() composeTestRule.waitForIdle() @@ -1235,19 +1236,19 @@ class SearchWorkerResultScreenTest { uid = "worker1", fieldOfWork = "Painter", rating = 4.5, - location = com.arygm.quickfix.model.locations.Location(40.0, -74.0, "Home"), + location = Location(40.0, -74.0, "Home"), tags = listOf("Interior Painter")), WorkerProfile( uid = "worker2", fieldOfWork = "Electrician", rating = 4.0, - location = com.arygm.quickfix.model.locations.Location(45.0, -75.0, "Far"), + location = Location(45.0, -75.0, "Far"), tags = listOf("Electrician")), WorkerProfile( uid = "worker3", fieldOfWork = "Plumber", rating = 3.5, - location = com.arygm.quickfix.model.locations.Location(42.0, -74.5, "Work"), + location = Location(42.0, -74.5, "Work"), tags = listOf("Plumber"))) searchViewModel._subCategoryWorkerProfiles.value = workers @@ -1264,7 +1265,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) // Apply Service Type filter = "Interior Painter" composeTestRule.onNodeWithText("Service Type").performClick() composeTestRule.waitForIdle() @@ -1277,7 +1278,7 @@ class SearchWorkerResultScreenTest { // Apply Location filter to get even more specific (Assume "Home") composeTestRule - .onNodeWithTag("lazy_filter_row") + .onNodeWithTag("filter_buttons_row") .performScrollToIndex(1) // scroll to "Location" if needed composeTestRule.onNodeWithText("Location").performClick() composeTestRule.waitForIdle() @@ -1289,7 +1290,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) // Now clear the Location filter but keep the Service Type filter - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) composeTestRule.onNodeWithText("Location").performClick() composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("resetButton").performClick() @@ -1331,7 +1332,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(4) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) // Apply Availability filter for today at 10:00 (both should be available) composeTestRule.onNodeWithText("Availability").performClick() composeTestRule.waitForIdle() @@ -1409,7 +1410,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("tuneButton").performClick() // Apply Highest Rating filter - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(3) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) composeTestRule.onNodeWithText("Highest Rating").performClick() composeTestRule.waitForIdle() @@ -1417,9 +1418,9 @@ class SearchWorkerResultScreenTest { val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() workerNodes.assertCountEquals(workers.size) // Verify order by rating text - workerNodes[0].assert(hasAnyChild(hasText("4.5 ★", substring = true))) - workerNodes[1].assert(hasAnyChild(hasText("3.0 ★", substring = true))) - workerNodes[2].assert(hasAnyChild(hasText("2.0 ★", substring = true))) + workerNodes[0].assert(hasText("4.5 ★", substring = true)) + workerNodes[1].assert(hasText("3.0 ★", substring = true)) + workerNodes[2].assert(hasText("2.0 ★", substring = true)) // Click again to remove Highest Rating filter composeTestRule.onNodeWithText("Highest Rating").performClick() @@ -1431,10 +1432,9 @@ class SearchWorkerResultScreenTest { // Check that the initial worker (w1) is now first again. val workerNodesAfterRevert = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - workerNodesAfterRevert[0].assert( - hasAnyChild(hasText("3.0 ★", substring = true))) // w1 first again - workerNodesAfterRevert[1].assert(hasAnyChild(hasText("4.5 ★", substring = true))) - workerNodesAfterRevert[2].assert(hasAnyChild(hasText("2.0 ★", substring = true))) + workerNodesAfterRevert[0].assert(hasText("3.0 ★", substring = true)) // w1 first again + workerNodesAfterRevert[1].assert(hasText("4.5 ★", substring = true)) + workerNodesAfterRevert[2].assert(hasText("2.0 ★", substring = true)) } @Test @@ -1449,17 +1449,17 @@ class SearchWorkerResultScreenTest { composeTestRule.waitForIdle() - // Initially, the lazy_filter_row might not be visible until we click the tune button - composeTestRule.onNodeWithTag("lazy_filter_row").assertDoesNotExist() + // Initially, the filter_buttons_row might not be visible until we click the tune button + composeTestRule.onNodeWithTag("filter_buttons_row").assertDoesNotExist() // Click the tune button to show filter buttons composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").assertIsDisplayed() + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() // Click the tune button again to hide filter buttons composeTestRule.onNodeWithTag("tuneButton").performClick() composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("lazy_filter_row").assertDoesNotExist() + composeTestRule.onNodeWithTag("filter_buttons_row").assertDoesNotExist() } @Test @@ -1479,7 +1479,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("tuneButton").performClick() // Attempt to open the Service Type filter - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) composeTestRule.onNodeWithText("Service Type").performClick() composeTestRule.waitForIdle() @@ -1521,7 +1521,7 @@ class SearchWorkerResultScreenTest { } composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) // Click on the "Service Type" filter button composeTestRule.onNodeWithText("Service Type").performClick() @@ -1561,7 +1561,7 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(sortedWorkers.size) sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasAnyChild(hasText("${worker.price}", substring = true))) + workerNodes[index].assert(hasText("${worker.price}", substring = true)) } } @@ -1611,7 +1611,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(3) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) // Simulate clicking the "Availability" filter button composeTestRule.onNodeWithText("Availability").performClick() @@ -1689,12 +1689,12 @@ class SearchWorkerResultScreenTest { listOf( WorkerProfile( uid = "worker1", - location = com.arygm.quickfix.model.locations.Location(40.0, -74.0, "Home"), + location = Location(40.0, -74.0, "Home"), fieldOfWork = "Painter", rating = 4.5), WorkerProfile( uid = "worker2", - location = com.arygm.quickfix.model.locations.Location(45.0, -75.0, "Far"), + location = Location(45.0, -75.0, "Far"), fieldOfWork = "Electrician", rating = 4.0)) @@ -1713,7 +1713,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("tuneButton").performClick() // Scroll to the "Location" button in the LazyRow if needed - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) // Open the Location filter bottom sheet composeTestRule.onNodeWithText("Location").performClick() @@ -1725,7 +1725,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("applyButton").assertIsNotEnabled() // Select "Home" location - composeTestRule.onNodeWithText("Home").performClick() + composeTestRule.onNodeWithTag("locationOptionRow1").performClick() // Click Apply composeTestRule.onNodeWithTag("applyButton").performClick() @@ -1735,7 +1735,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) // Open Location filter again to clear - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(1) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) composeTestRule.onNodeWithText("Location").performClick() composeTestRule.waitForIdle() @@ -1744,7 +1744,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("applyButton").assertIsEnabled() - composeTestRule.onNodeWithText("Home").performClick() + composeTestRule.onNodeWithTag("locationOptionRow1").performClick() // Clear the filter composeTestRule.onNodeWithTag("resetButton").performClick() diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/CategoryContents.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/CategoryContents.kt index 659c358a..e84e684c 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/CategoryContents.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/CategoryContents.kt @@ -18,7 +18,6 @@ import com.arygm.quickfix.model.category.Category import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography -import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.ExpandableCategoryItem @Composable fun CategoryContent( diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt index b460ec6d..f3defb16 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt @@ -26,11 +26,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.BookmarkBorder -import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Text @@ -49,6 +46,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.arygm.quickfix.ui.elements.QuickFixButton import com.arygm.quickfix.ui.elements.QuickFixSlidingWindow +import com.arygm.quickfix.ui.elements.RatingBar @OptIn(ExperimentalLayoutApi::class) @Composable @@ -317,65 +315,53 @@ fun QuickFixSlidingWindowWorker( modifier = Modifier.padding(horizontal = screenWidth * 0.04f) .testTag("sliding_window_star_rating_row")) { - val filledStars = workerRating.toInt() - val unfilledStars = 5 - filledStars - repeat(filledStars) { - Icon( - imageVector = Icons.Filled.Star, - contentDescription = null, - tint = colorScheme.onBackground) - } - repeat(unfilledStars) { - Icon( - imageVector = Icons.Outlined.StarOutline, - contentDescription = null, - tint = colorScheme.onBackground, - ) - } + RatingBar( + workerRating.toFloat(), + modifier = Modifier.height(20.dp).testTag("starsRow")) } - Spacer(modifier = Modifier.height(screenHeight * 0.01f)) - LazyRow( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_reviews_row")) { - itemsIndexed(reviews) { index, review -> - var isExpanded by remember { mutableStateOf(false) } - val displayText = - if (isExpanded || review.length <= 100) { - review - } else { - review.take(100) + "..." - } - - Box( - modifier = - Modifier.padding(end = screenWidth * 0.02f) - .width(screenWidth * 0.6f) - .clip(RoundedCornerShape(25f)) - .background(colorScheme.background)) { - Column(modifier = Modifier.padding(screenWidth * 0.02f)) { - Text( - text = displayText, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface) - if (review.length > 100) { - Text( - text = if (isExpanded) "See less" else "See more", - style = - MaterialTheme.typography.bodySmall.copy( - color = colorScheme.primary), - modifier = - Modifier.clickable { isExpanded = !isExpanded } - .padding(top = screenHeight * 0.01f)) - } - } - } + } + Spacer(modifier = Modifier.height(screenHeight * 0.01f)) + LazyRow( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_reviews_row")) { + itemsIndexed(reviews) { index, review -> + var isExpanded by remember { mutableStateOf(false) } + val displayText = + if (isExpanded || review.length <= 100) { + review + } else { + review.take(100) + "..." } - } - Spacer(modifier = Modifier.height(screenHeight * 0.02f)) + Box( + modifier = + Modifier.padding(end = screenWidth * 0.02f) + .width(screenWidth * 0.6f) + .clip(RoundedCornerShape(25f)) + .background(colorScheme.background)) { + Column(modifier = Modifier.padding(screenWidth * 0.02f)) { + Text( + text = displayText, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface) + if (review.length > 100) { + Text( + text = if (isExpanded) "See less" else "See more", + style = + MaterialTheme.typography.bodySmall.copy( + color = colorScheme.primary), + modifier = + Modifier.clickable { isExpanded = !isExpanded } + .padding(top = screenHeight * 0.01f)) + } + } + } + } } + + Spacer(modifier = Modifier.height(screenHeight * 0.02f)) } } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt index ba078753..45503043 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag @@ -203,26 +204,28 @@ fun FilterRow( Spacer(modifier = Modifier.width(10.dp)) AnimatedVisibility(visible = showFilterButtons) { - LazyRow(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - items(listOfButtons.size) { index -> - val button = listOfButtons[index] - QuickFixButton( - buttonText = button.text, - onClickAction = button.onClick, - buttonColor = if (button.applied) colorScheme.primary else colorScheme.surface, - textColor = if (button.applied) colorScheme.onPrimary else colorScheme.onBackground, - textStyle = poppinsTypography.labelSmall.copy(fontWeight = FontWeight.Medium), - height = screenHeight * 0.05f, - leadingIcon = button.leadingIcon, - trailingIcon = button.trailingIcon, - leadingIconTint = - if (button.applied) colorScheme.onPrimary else colorScheme.onBackground, - trailingIconTint = - if (button.applied) colorScheme.onPrimary else colorScheme.onBackground, - contentPadding = PaddingValues(vertical = 0.dp, horizontal = screenWidth * 0.02f), - modifier = Modifier.testTag("filter_button_${button.text}")) - Spacer(modifier = Modifier.width(screenHeight * 0.01f)) - } - } + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.testTag("filter_buttons_row")) { + items(listOfButtons.size) { index -> + val button = listOfButtons[index] + QuickFixButton( + buttonText = button.text, + onClickAction = button.onClick, + buttonColor = if (button.applied) colorScheme.primary else colorScheme.surface, + textColor = if (button.applied) colorScheme.onPrimary else colorScheme.onBackground, + textStyle = poppinsTypography.labelSmall.copy(fontWeight = FontWeight.Medium), + height = screenHeight * 0.05f, + leadingIcon = button.leadingIcon, + trailingIcon = button.trailingIcon, + leadingIconTint = + if (button.applied) colorScheme.onPrimary else colorScheme.onBackground, + trailingIconTint = + if (button.applied) colorScheme.onPrimary else colorScheme.onBackground, + contentPadding = PaddingValues(vertical = 0.dp, horizontal = screenWidth * 0.02f), + modifier = Modifier.testTag("filter_button_${button.text}")) + Spacer(modifier = Modifier.width(screenHeight * 0.01f)) + } + } } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index c921a9d6..dff64473 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -213,8 +213,7 @@ fun SearchWorkerResult( modifier = Modifier.fillMaxWidth() .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) - .padding(horizontal = screenWidth * 0.02f) - .testTag("filter_buttons_row"), + .padding(horizontal = screenWidth * 0.02f), verticalAlignment = Alignment.CenterVertically, ) { FilterRow( From f437f78ea1c834942ef96bc80fc093b695fd0d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Sat, 14 Dec 2024 02:11:01 +0100 Subject: [PATCH 06/15] Fix: update of tests to match refactoring of SearchWorkerResult.kt now all tests should pass --- .../ui/search/SearchWorkerResultScreenTest.kt | 10 +- .../userModeUI/search/Announcement.kt | 1200 ++++++++--------- .../search/ExpandableCategoryItem.kt | 180 ++- .../userModeUI/search/QuickFixFinder.kt | 353 +++-- .../userModeUI/search/SearchWorkerResult.kt | 13 +- 5 files changed, 812 insertions(+), 944 deletions(-) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt index f0b09aa9..65d3cf95 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt @@ -19,11 +19,9 @@ import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performTextReplacement -import androidx.compose.ui.test.printToLog import androidx.datastore.preferences.core.Preferences import androidx.test.ext.junit.runners.AndroidJUnit4 import com.arygm.quickfix.model.account.Account @@ -483,7 +481,6 @@ class SearchWorkerResultScreenTest { // Check for each tag val tags = listOf("Painter", "Gardener") - composeTestRule.onRoot().printToLog("root") tags.forEach { tag -> composeTestRule.onNodeWithText(tag).assertExists().assertIsDisplayed() } } @@ -1670,7 +1667,7 @@ class SearchWorkerResultScreenTest { // Find the node representing today's date and perform a click composeTestRule .onNode( - hasText(LocalDate.now().plusDays(1).dayOfMonth.toString()) and + hasText(LocalDate.now().plusDays(0).dayOfMonth.toString()) and hasClickAction() and !hasSetTextAction()) .performClick() @@ -1679,7 +1676,7 @@ class SearchWorkerResultScreenTest { composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) } @Test @@ -1729,7 +1726,6 @@ class SearchWorkerResultScreenTest { // Click Apply composeTestRule.onNodeWithTag("applyButton").performClick() - composeTestRule.waitForIdle() // Verify that only the worker at "Home" is displayed (worker1) composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) @@ -1744,7 +1740,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("applyButton").assertIsEnabled() - composeTestRule.onNodeWithTag("locationOptionRow1").performClick() + composeTestRule.onNodeWithTag("locationOptionRow0").performClick() // Clear the filter composeTestRule.onNodeWithTag("resetButton").performClick() diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt index 2f319b22..8e7461a3 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt @@ -116,214 +116,205 @@ fun AnnouncementScreen( initialAvailability: List> = emptyList(), initialUploadedImages: List = emptyList() ) { - var title by rememberSaveable { mutableStateOf(initialTitle) } - var subcategoryTitle by rememberSaveable { mutableStateOf(initialSubcategoryTitle) } - var description by rememberSaveable { mutableStateOf(initialDescription) } - - var locationLat by rememberSaveable { mutableStateOf(initialLocation?.latitude) } - var locationLon by rememberSaveable { mutableStateOf(initialLocation?.longitude) } - var locationName by rememberSaveable { mutableStateOf(initialLocation?.name) } - var locationTitle by rememberSaveable { mutableStateOf(initialLocation?.name ?: "") } - var locationIsSelected by rememberSaveable { mutableStateOf(initialLocation != null) } - - val loggedInAccount by loggedInAccountViewModel.loggedInAccount.collectAsState() - val userId = loggedInAccount?.uid ?: "Should not happen" - - var selectedSubcategoryName by rememberSaveable { mutableStateOf("") } - - LaunchedEffect(Unit) { - // Charger les images initiales - initialUploadedImages.forEach { announcementViewModel.addUploadedImage(it) } + var title by rememberSaveable { mutableStateOf(initialTitle) } + var subcategoryTitle by rememberSaveable { mutableStateOf(initialSubcategoryTitle) } + var description by rememberSaveable { mutableStateOf(initialDescription) } + + var locationLat by rememberSaveable { mutableStateOf(initialLocation?.latitude) } + var locationLon by rememberSaveable { mutableStateOf(initialLocation?.longitude) } + var locationName by rememberSaveable { mutableStateOf(initialLocation?.name) } + var locationTitle by rememberSaveable { mutableStateOf(initialLocation?.name ?: "") } + var locationIsSelected by rememberSaveable { mutableStateOf(initialLocation != null) } + + val loggedInAccount by loggedInAccountViewModel.loggedInAccount.collectAsState() + val userId = loggedInAccount?.uid ?: "Should not happen" + + var selectedSubcategoryName by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(Unit) { + // Charger les images initiales + initialUploadedImages.forEach { announcementViewModel.addUploadedImage(it) } + } + val location = + if (locationLat != null && locationLon != null && locationName != null) { + Location(latitude = locationLat!!, longitude = locationLon!!, name = locationName!!) + } else null + + var locationExpanded by remember { mutableStateOf(false) } + val locationSuggestions by locationViewModel.locationSuggestions.collectAsState() + + var titleIsEmpty by rememberSaveable { mutableStateOf(true) } + var descriptionIsEmpty by rememberSaveable { mutableStateOf(true) } + + fun LocalDateTime.toMillis(): Long = + this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + fun millisToLocalDateTime(millis: Long): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault()) + + val availabilitySaver = + androidx.compose.runtime.saveable.Saver>, List>>( + save = { list -> list.map { pair -> listOf(pair.first, pair.second) } }, + restore = { saved -> saved.mapNotNull { if (it.size == 2) it[0] to it[1] else null } }) + + var isEditingIndex by rememberSaveable { mutableStateOf(null) } + var showStartAvailabilityPopup by remember { mutableStateOf(false) } + var showEndAvailabilityPopup by remember { mutableStateOf(false) } + var tempStartMillis by remember { mutableStateOf(null) } + + val dateFormatter = DateTimeFormatter.ofPattern("EEE, dd MMM") + val timeFormatter = DateTimeFormatter.ofPattern("hh:mm a") + + var subcategoryExpanded by remember { mutableStateOf(false) } + + val uploadedImages by announcementViewModel.uploadedImages.collectAsState() + var showUploadImageSheet by rememberSaveable { mutableStateOf(false) } + + val categories by categoryViewModel.categories.collectAsState() + val allSubcategories = categories.flatMap { it.subcategories } + + val selectedSubcategory = allSubcategories.find { it.name == selectedSubcategoryName } + val categoryIsSelected = selectedSubcategory != null + + LaunchedEffect(Unit) { categoryViewModel.getCategories() } + + val sheetState = rememberModalBottomSheetState() + var listAvailability by + rememberSaveable(stateSaver = availabilitySaver) { mutableStateOf(initialAvailability) } + val resetAnnouncementParameters = { + title = "" + subcategoryTitle = "" + selectedSubcategoryName = "" + description = "" + locationLat = null + locationLon = null + locationName = null + locationTitle = "" + titleIsEmpty = true + descriptionIsEmpty = true + locationIsSelected = false + listAvailability = emptyList() + navigationActions.saveToCurBackStack("selectedLocation", null) + announcementViewModel.clearUploadedImages() + } + + val updateUserProfileWithAnnouncement: (Announcement) -> Unit = { announcement -> + profileViewModel.fetchUserProfile(userId) { profile -> + if (profile is UserProfile) { + val announcementList = profile.announcements + announcement.announcementId + profileViewModel.updateProfile( + UserProfile(profile.locations, announcementList, profile.wallet, profile.uid), + onSuccess = { + accountViewModel.fetchUserAccount(profile.uid) { account -> + loggedInAccountViewModel.setLoggedInAccount(account!!) + } + }, + onFailure = { e -> + Log.e("ProfileViewModel", "Failed to update profile: ${e.message}") + }) + } else { + Log.e("Wrong profile", "Should be a user profile") + } } - val location = - if (locationLat != null && locationLon != null && locationName != null) { - Location(latitude = locationLat!!, longitude = locationLon!!, name = locationName!!) - } else null - - var locationExpanded by remember { mutableStateOf(false) } - val locationSuggestions by locationViewModel.locationSuggestions.collectAsState() - - var titleIsEmpty by rememberSaveable { mutableStateOf(true) } - var descriptionIsEmpty by rememberSaveable { mutableStateOf(true) } - - fun LocalDateTime.toMillis(): Long = - this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - - fun millisToLocalDateTime(millis: Long): LocalDateTime = - LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault()) - - val availabilitySaver = - androidx.compose.runtime.saveable.Saver>, List>>( - save = { list -> list.map { pair -> listOf(pair.first, pair.second) } }, - restore = { saved -> saved.mapNotNull { if (it.size == 2) it[0] to it[1] else null } }) - - var isEditingIndex by rememberSaveable { mutableStateOf(null) } - var showStartAvailabilityPopup by remember { mutableStateOf(false) } - var showEndAvailabilityPopup by remember { mutableStateOf(false) } - var tempStartMillis by remember { mutableStateOf(null) } - - val dateFormatter = DateTimeFormatter.ofPattern("EEE, dd MMM") - val timeFormatter = DateTimeFormatter.ofPattern("hh:mm a") - - var subcategoryExpanded by remember { mutableStateOf(false) } - - val uploadedImages by announcementViewModel.uploadedImages.collectAsState() - var showUploadImageSheet by rememberSaveable { mutableStateOf(false) } - - val categories by categoryViewModel.categories.collectAsState() - val allSubcategories = categories.flatMap { it.subcategories } - - val selectedSubcategory = allSubcategories.find { it.name == selectedSubcategoryName } - val categoryIsSelected = selectedSubcategory != null - - LaunchedEffect(Unit) { categoryViewModel.getCategories() } - - val sheetState = rememberModalBottomSheetState() - var listAvailability by - rememberSaveable(stateSaver = availabilitySaver) { mutableStateOf(initialAvailability) } - val resetAnnouncementParameters = { - title = "" - subcategoryTitle = "" - selectedSubcategoryName = "" - description = "" - locationLat = null - locationLon = null - locationName = null - locationTitle = "" - titleIsEmpty = true - descriptionIsEmpty = true - locationIsSelected = false - listAvailability = emptyList() - navigationActions.saveToCurBackStack("selectedLocation", null) - announcementViewModel.clearUploadedImages() - } - - val updateUserProfileWithAnnouncement: (Announcement) -> Unit = { announcement -> - profileViewModel.fetchUserProfile(userId) { profile -> - if (profile is UserProfile) { - val announcementList = profile.announcements + announcement.announcementId - profileViewModel.updateProfile( - UserProfile(profile.locations, announcementList, profile.wallet, profile.uid), - onSuccess = { - accountViewModel.fetchUserAccount(profile.uid) { account -> - loggedInAccountViewModel.setLoggedInAccount(account!!) - } - }, - onFailure = { e -> - Log.e("ProfileViewModel", "Failed to update profile: ${e.message}") - }) - } else { - Log.e("Wrong profile", "Should be a user profile") + } + + val handleSuccessfulImageUpload: (String, List) -> Unit = + { announcementId, uploadedImageUrls -> + val availabilitySlots = + listAvailability.map { (startMillis, endMillis) -> + val start = millisToTimestamp(startMillis) + val end = millisToTimestamp(endMillis) + AvailabilitySlot(start = start, end = end) } - } - } - val handleSuccessfulImageUpload: (String, List) -> Unit = - { announcementId, uploadedImageUrls -> - val availabilitySlots = - listAvailability.map { (startMillis, endMillis) -> - val start = millisToTimestamp(startMillis) - val end = millisToTimestamp(endMillis) - AvailabilitySlot(start = start, end = end) - } - - val announcement = - Announcement( - announcementId = announcementId, - userId = userId, - title = title, - category = selectedSubcategory?.name ?: "", - description = description, - location = location, - availability = availabilitySlots, - quickFixImages = uploadedImageUrls - ) - announcementViewModel.announce(announcement) - updateUserProfileWithAnnouncement(announcement) - resetAnnouncementParameters() - } - - if (showStartAvailabilityPopup) { - Dialog(onDismissRequest = { showStartAvailabilityPopup = false }) { - println("showStartAvailabilityPopup") - QuickFixDateTimePicker( - onDateTimeSelected = { date, time -> - val start = LocalDateTime.of(date, time) - tempStartMillis = start.toMillis() - showStartAvailabilityPopup = false - showEndAvailabilityPopup = true - }, - onDismissRequest = { showStartAvailabilityPopup = false }, - modifier = Modifier.testTag("startAvailabilityPicker") - ) - } + val announcement = + Announcement( + announcementId = announcementId, + userId = userId, + title = title, + category = selectedSubcategory?.name ?: "", + description = description, + location = location, + availability = availabilitySlots, + quickFixImages = uploadedImageUrls) + announcementViewModel.announce(announcement) + updateUserProfileWithAnnouncement(announcement) + resetAnnouncementParameters() + } + + if (showStartAvailabilityPopup) { + Dialog(onDismissRequest = { showStartAvailabilityPopup = false }) { + println("showStartAvailabilityPopup") + QuickFixDateTimePicker( + onDateTimeSelected = { date, time -> + val start = LocalDateTime.of(date, time) + tempStartMillis = start.toMillis() + showStartAvailabilityPopup = false + showEndAvailabilityPopup = true + }, + onDismissRequest = { showStartAvailabilityPopup = false }, + modifier = Modifier.testTag("startAvailabilityPicker")) } - - if (showEndAvailabilityPopup) { - Dialog(onDismissRequest = { showEndAvailabilityPopup = false }) { - QuickFixDateTimePicker( - onDateTimeSelected = { date, time -> - val end = LocalDateTime.of(date, time) - tempStartMillis?.let { startMillis -> - if (isEditingIndex == null) { - listAvailability = listAvailability + (startMillis to end.toMillis()) - } else { - val mutable = listAvailability.toMutableList() - mutable[isEditingIndex!!] = (startMillis to end.toMillis()) - listAvailability = mutable - isEditingIndex = null - } - } - tempStartMillis = null - showEndAvailabilityPopup = false - }, - onDismissRequest = { showEndAvailabilityPopup = false }, - modifier = Modifier.testTag("endAvailabilityPicker") - ) - } + } + + if (showEndAvailabilityPopup) { + Dialog(onDismissRequest = { showEndAvailabilityPopup = false }) { + QuickFixDateTimePicker( + onDateTimeSelected = { date, time -> + val end = LocalDateTime.of(date, time) + tempStartMillis?.let { startMillis -> + if (isEditingIndex == null) { + listAvailability = listAvailability + (startMillis to end.toMillis()) + } else { + val mutable = listAvailability.toMutableList() + mutable[isEditingIndex!!] = (startMillis to end.toMillis()) + listAvailability = mutable + isEditingIndex = null + } + } + tempStartMillis = null + showEndAvailabilityPopup = false + }, + onDismissRequest = { showEndAvailabilityPopup = false }, + modifier = Modifier.testTag("endAvailabilityPicker")) } - - BoxWithConstraints { - val widthRatio = maxWidth / 411 - val heightRatio = maxHeight / 860 - - val categoryTextStyle = - MaterialTheme.typography.labelMedium.copy( - fontSize = 10.sp, color = colorScheme.onBackground, fontWeight = FontWeight.Medium - ) - - val maxCategoryTextWidth = - calculateMaxTextWidth( - texts = allSubcategories.map { it.name }, textStyle = categoryTextStyle - ) - - val dropdownMenuWidth = maxCategoryTextWidth + 40.dp - - Scaffold( - containerColor = colorScheme.surface, - topBar = {}, - modifier = Modifier.testTag("AnnouncementContent") - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .padding( - start = 14.dp * widthRatio.value, - end = 14.dp * widthRatio.value, - top = 30.dp * heightRatio.value - ) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top - ) { + } + + BoxWithConstraints { + val widthRatio = maxWidth / 411 + val heightRatio = maxHeight / 860 + + val categoryTextStyle = + MaterialTheme.typography.labelMedium.copy( + fontSize = 10.sp, color = colorScheme.onBackground, fontWeight = FontWeight.Medium) + + val maxCategoryTextWidth = + calculateMaxTextWidth( + texts = allSubcategories.map { it.name }, textStyle = categoryTextStyle) + + val dropdownMenuWidth = maxCategoryTextWidth + 40.dp + + Scaffold( + containerColor = colorScheme.surface, + topBar = {}, + modifier = Modifier.testTag("AnnouncementContent")) { padding -> + Column( + modifier = + Modifier.fillMaxSize() + .padding(padding) + .padding( + start = 14.dp * widthRatio.value, + end = 14.dp * widthRatio.value, + top = 30.dp * heightRatio.value) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top) { // Title QuickFixTextFieldCustom( value = title, onValueChange = { - title = it - titleIsEmpty = title.isEmpty() + title = it + titleIsEmpty = title.isEmpty() }, placeHolderText = "Enter the title of your quickFix", placeHolderColor = colorScheme.onSecondaryContainer, @@ -333,97 +324,87 @@ fun AnnouncementScreen( widthField = 380.dp * widthRatio.value, showLabel = true, label = { - Text( - text = - buildAnnotatedString { + Text( + text = + buildAnnotatedString { append("Title") withStyle(style = SpanStyle(color = colorScheme.primary)) { - append(" *") + append(" *") } - }, - style = - MaterialTheme.typography.headlineMedium.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), - color = colorScheme.onBackground, - modifier = Modifier.testTag("titleText") - ) + }, + style = + MaterialTheme.typography.headlineMedium.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = colorScheme.onBackground, + modifier = Modifier.testTag("titleText")) }, hasShadow = false, borderColor = colorScheme.tertiaryContainer, - modifier = Modifier.testTag("titleInput") - ) + modifier = Modifier.testTag("titleInput")) Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) // Subcategory Text( text = - buildAnnotatedString { - append("Subcategory") - withStyle(style = SpanStyle(color = colorScheme.primary)) { append(" *") } - }, + buildAnnotatedString { + append("Subcategory") + withStyle(style = SpanStyle(color = colorScheme.primary)) { append(" *") } + }, style = - MaterialTheme.typography.headlineMedium.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), + MaterialTheme.typography.headlineMedium.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), color = colorScheme.onBackground, - modifier = Modifier.testTag("categoryText") - ) + modifier = Modifier.testTag("categoryText")) Box { - QuickFixTextFieldCustom( - value = subcategoryTitle, - onValueChange = { newValue -> - subcategoryTitle = newValue - subcategoryExpanded = - newValue.isNotEmpty() && allSubcategories.isNotEmpty() - }, - placeHolderText = "Select a subcategory", - placeHolderColor = colorScheme.onSecondaryContainer, - shape = RoundedCornerShape(8.dp), - moveContentHorizontal = 10.dp, - heightField = 40.dp * heightRatio.value, - widthField = 380.dp * widthRatio.value, - showLabel = false, - hasShadow = false, - borderColor = colorScheme.tertiaryContainer, - modifier = - Modifier.testTag("categoryInput") // This will serve as category input - ) - - DropdownMenu( - expanded = subcategoryExpanded, - properties = PopupProperties(focusable = false), - onDismissRequest = { subcategoryExpanded = false }, - modifier = Modifier.width(dropdownMenuWidth * widthRatio.value), - containerColor = colorScheme.surface - ) { + QuickFixTextFieldCustom( + value = subcategoryTitle, + onValueChange = { newValue -> + subcategoryTitle = newValue + subcategoryExpanded = newValue.isNotEmpty() && allSubcategories.isNotEmpty() + }, + placeHolderText = "Select a subcategory", + placeHolderColor = colorScheme.onSecondaryContainer, + shape = RoundedCornerShape(8.dp), + moveContentHorizontal = 10.dp, + heightField = 40.dp * heightRatio.value, + widthField = 380.dp * widthRatio.value, + showLabel = false, + hasShadow = false, + borderColor = colorScheme.tertiaryContainer, + modifier = + Modifier.testTag("categoryInput") // This will serve as category input + ) + + DropdownMenu( + expanded = subcategoryExpanded, + properties = PopupProperties(focusable = false), + onDismissRequest = { subcategoryExpanded = false }, + modifier = Modifier.width(dropdownMenuWidth * widthRatio.value), + containerColor = colorScheme.surface) { val filteredSubcategories = allSubcategories.filter { - it.name.contains(subcategoryTitle, ignoreCase = true) + it.name.contains(subcategoryTitle, ignoreCase = true) } filteredSubcategories.forEachIndexed { index, sub -> - DropdownMenuItem( - text = { Text(text = sub.name, style = categoryTextStyle) }, - onClick = { - subcategoryExpanded = false - subcategoryTitle = sub.name - selectedSubcategoryName = sub.name - }, - modifier = - Modifier - .height(30.dp * heightRatio.value) - .testTag("subcategoryItem$index") - ) - if (index < filteredSubcategories.size - 1) { - HorizontalDivider( - color = colorScheme.onSecondaryContainer, thickness = 1.5.dp - ) - } + DropdownMenuItem( + text = { Text(text = sub.name, style = categoryTextStyle) }, + onClick = { + subcategoryExpanded = false + subcategoryTitle = sub.name + selectedSubcategoryName = sub.name + }, + modifier = + Modifier.height(30.dp * heightRatio.value) + .testTag("subcategoryItem$index")) + if (index < filteredSubcategories.size - 1) { + HorizontalDivider( + color = colorScheme.onSecondaryContainer, thickness = 1.5.dp) + } } - } + } } Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) @@ -432,8 +413,8 @@ fun AnnouncementScreen( QuickFixTextFieldCustom( value = description, onValueChange = { - description = it - descriptionIsEmpty = description.isEmpty() + description = it + descriptionIsEmpty = description.isEmpty() }, placeHolderText = "Describe the quickFix", placeHolderColor = colorScheme.onSecondaryContainer, @@ -443,21 +424,19 @@ fun AnnouncementScreen( widthField = 380.dp * widthRatio.value, showLabel = true, label = { - Text( - text = - buildAnnotatedString { + Text( + text = + buildAnnotatedString { append("Description") withStyle(style = SpanStyle(color = colorScheme.primary)) { - append(" *") + append(" *") } - }, - style = - MaterialTheme.typography.headlineMedium.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), - color = colorScheme.onBackground, - modifier = Modifier.testTag("descriptionText") - ) + }, + style = + MaterialTheme.typography.headlineMedium.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = colorScheme.onBackground, + modifier = Modifier.testTag("descriptionText")) }, hasShadow = false, borderColor = colorScheme.tertiaryContainer, @@ -466,38 +445,34 @@ fun AnnouncementScreen( showCharCounter = true, moveCounter = 17.dp, charCounterTextStyle = - MaterialTheme.typography.bodySmall.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), + MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), charCounterColor = colorScheme.onSecondaryContainer, - modifier = Modifier.testTag("descriptionInput") - ) + modifier = Modifier.testTag("descriptionInput")) Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) // Location Text( text = - buildAnnotatedString { - append("Location") - withStyle(style = SpanStyle(color = colorScheme.primary)) { append(" *") } - }, + buildAnnotatedString { + append("Location") + withStyle(style = SpanStyle(color = colorScheme.primary)) { append(" *") } + }, style = - MaterialTheme.typography.headlineMedium.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), + MaterialTheme.typography.headlineMedium.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), color = colorScheme.onBackground, - modifier = Modifier.testTag("locationText") - ) + modifier = Modifier.testTag("locationText")) QuickFixTextFieldCustom( value = locationTitle, onValueChange = { - locationExpanded = it.isNotEmpty() && locationSuggestions.isNotEmpty() - locationTitle = it - if (it.isNotEmpty()) { - locationViewModel.setQuery(it) - } + locationExpanded = it.isNotEmpty() && locationSuggestions.isNotEmpty() + locationTitle = it + if (it.isNotEmpty()) { + locationViewModel.setQuery(it) + } }, singleLine = true, placeHolderText = "Location", @@ -512,8 +487,7 @@ fun AnnouncementScreen( borderColor = colorScheme.tertiaryContainer, borderThickness = 1.5.dp, textStyle = poppinsTypography.labelSmall.copy(fontWeight = FontWeight.Medium), - modifier = Modifier.testTag("locationInput") - ) + modifier = Modifier.testTag("locationInput")) DropdownMenu( expanded = locationExpanded, @@ -522,59 +496,54 @@ fun AnnouncementScreen( modifier = Modifier.width(380.dp * widthRatio.value), containerColor = colorScheme.surface, ) { - locationSuggestions.forEachIndexed { index, suggestion -> - DropdownMenuItem( - onClick = { - locationExpanded = false - locationViewModel.setQuery(suggestion.name) - locationTitle = suggestion.name - locationLat = suggestion.latitude - locationLon = suggestion.longitude - locationName = suggestion.name - locationIsSelected = true - }, - text = { - Text( - text = suggestion.name, - style = poppinsTypography.labelSmall, - fontWeight = FontWeight.Medium, - color = colorScheme.onBackground, - modifier = Modifier.padding(horizontal = 4.dp) - ) - }, - modifier = Modifier.testTag("locationSuggestionItem") - ) - if (index < locationSuggestions.size - 1) { - HorizontalDivider( - color = colorScheme.onSecondaryContainer, thickness = 1.5.dp - ) - } + locationSuggestions.forEachIndexed { index, suggestion -> + DropdownMenuItem( + onClick = { + locationExpanded = false + locationViewModel.setQuery(suggestion.name) + locationTitle = suggestion.name + locationLat = suggestion.latitude + locationLon = suggestion.longitude + locationName = suggestion.name + locationIsSelected = true + }, + text = { + Text( + text = suggestion.name, + style = poppinsTypography.labelSmall, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 4.dp)) + }, + modifier = Modifier.testTag("locationSuggestionItem")) + if (index < locationSuggestions.size - 1) { + HorizontalDivider( + color = colorScheme.onSecondaryContainer, thickness = 1.5.dp) } + } } Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) // Availability val startAvailability = { - isEditingIndex = null - showStartAvailabilityPopup = true + isEditingIndex = null + showStartAvailabilityPopup = true } if (listAvailability.isEmpty()) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth()) { Button( onClick = startAvailability, modifier = Modifier.padding(vertical = 16.dp * heightRatio.value), shape = RoundedCornerShape(10.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier.wrapContentWidth() - ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + modifier = Modifier.wrapContentWidth()) { Icon( painter = painterResource(R.drawable.calendar), contentDescription = "Calendar", @@ -585,169 +554,143 @@ fun AnnouncementScreen( text = "Add Availability", style = poppinsTypography.labelSmall, color = colorScheme.onPrimary, - fontWeight = FontWeight.Bold - ) - } + fontWeight = FontWeight.Bold) + } } - } + } } else { - Row( - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.Top, - modifier = Modifier.fillMaxWidth() - ) { + Row( + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth()) { Column( modifier = - Modifier - .fillMaxWidth() - .padding(top = 16.dp * heightRatio.value, start = 4.dp) - .weight(0.8f), - horizontalAlignment = Alignment.Start - ) { - Text( - text = "Availability", - style = poppinsTypography.headlineMedium, - color = colorScheme.onBackground, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - modifier = Modifier.padding(bottom = 16.dp * heightRatio.value) - ) - Row( - modifier = Modifier - .fillMaxWidth(0.8f) - .padding(bottom = 4.dp), - ) { + Modifier.fillMaxWidth() + .padding(top = 16.dp * heightRatio.value, start = 4.dp) + .weight(0.8f), + horizontalAlignment = Alignment.Start) { + Text( + text = "Availability", + style = poppinsTypography.headlineMedium, + color = colorScheme.onBackground, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + modifier = Modifier.padding(bottom = 16.dp * heightRatio.value)) + Row( + modifier = Modifier.fillMaxWidth(0.8f).padding(bottom = 4.dp), + ) { Text( text = "Day", style = poppinsTypography.labelSmall, color = colorScheme.onBackground, fontWeight = FontWeight.Medium, - modifier = Modifier.weight(0.42f) - ) + modifier = Modifier.weight(0.42f)) Text( text = "Time", style = poppinsTypography.labelSmall, color = colorScheme.onBackground, fontWeight = FontWeight.Medium, - modifier = Modifier.weight(0.38f) - ) + modifier = Modifier.weight(0.38f)) + } } - } IconButton( onClick = startAvailability, modifier = - Modifier - .testTag("Add Availability Button") - .padding(top = 16.dp * heightRatio.value, end = 4.dp) - .weight(0.2f), + Modifier.testTag("Add Availability Button") + .padding(top = 16.dp * heightRatio.value, end = 4.dp) + .weight(0.2f), content = { - Icon( - imageVector = Icons.Default.Add, - contentDescription = "Add Availability", - ) + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add Availability", + ) }, colors = - IconButtonDefaults.iconButtonColors( - contentColor = colorScheme.primary, - ) - ) - } - - listAvailability.forEachIndexed { index, (startMillis, endMillis) -> - HorizontalDivider( - color = colorScheme.background, - thickness = 1.5.dp, - modifier = Modifier - .fillMaxWidth(0.5f) - .padding(start = 4.dp) - ) - - val start = millisToLocalDateTime(startMillis) - val end = millisToLocalDateTime(endMillis) - - val startDay = start.toLocalDate().format(dateFormatter) - val endDay = end.toLocalDate().format(dateFormatter) - val startTimeText = start.toLocalTime().format(timeFormatter) - val endTimeText = end.toLocalTime().format(timeFormatter) - - val dayText = - if (startDay == endDay) { - startDay - } else { - "$startDay - $endDay" - } + IconButtonDefaults.iconButtonColors( + contentColor = colorScheme.primary, + )) + } + + listAvailability.forEachIndexed { index, (startMillis, endMillis) -> + HorizontalDivider( + color = colorScheme.background, + thickness = 1.5.dp, + modifier = Modifier.fillMaxWidth(0.5f).padding(start = 4.dp)) + + val start = millisToLocalDateTime(startMillis) + val end = millisToLocalDateTime(endMillis) + + val startDay = start.toLocalDate().format(dateFormatter) + val endDay = end.toLocalDate().format(dateFormatter) + val startTimeText = start.toLocalTime().format(timeFormatter) + val endTimeText = end.toLocalTime().format(timeFormatter) + + val dayText = + if (startDay == endDay) { + startDay + } else { + "$startDay - $endDay" + } - val timeText = "$startTimeText - $endTimeText" + val timeText = "$startTimeText - $endTimeText" - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .fillMaxWidth() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier.fillMaxWidth() .padding(horizontal = 4.dp) - .testTag("availabilitySlot") - ) { - Text( - text = dayText, - style = poppinsTypography.labelSmall, - color = colorScheme.onBackground, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(0.335f) - ) - Text( - text = timeText, - style = poppinsTypography.labelSmall, - color = colorScheme.onBackground, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(0.35f) - ) - TextButton( - onClick = { - isEditingIndex = index - tempStartMillis = startMillis - showStartAvailabilityPopup = true - }, - modifier = Modifier - .wrapContentWidth() - .weight(0.15f), - shape = RoundedCornerShape(10.dp), - colors = - ButtonDefaults.textButtonColors( - contentColor = colorScheme.primary, - ), - contentPadding = PaddingValues(0.dp) - ) { + .testTag("availabilitySlot")) { + Text( + text = dayText, + style = poppinsTypography.labelSmall, + color = colorScheme.onBackground, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(0.335f)) + Text( + text = timeText, + style = poppinsTypography.labelSmall, + color = colorScheme.onBackground, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(0.35f)) + TextButton( + onClick = { + isEditingIndex = index + tempStartMillis = startMillis + showStartAvailabilityPopup = true + }, + modifier = Modifier.wrapContentWidth().weight(0.15f), + shape = RoundedCornerShape(10.dp), + colors = + ButtonDefaults.textButtonColors( + contentColor = colorScheme.primary, + ), + contentPadding = PaddingValues(0.dp)) { Text( text = "Edit", style = poppinsTypography.labelSmall, - fontWeight = FontWeight.SemiBold - ) - } - TextButton( - onClick = { - listAvailability = - listAvailability.toMutableList().apply { removeAt(index) } - }, - modifier = Modifier - .wrapContentWidth() - .weight(0.15f), - shape = RoundedCornerShape(10.dp), - colors = - ButtonDefaults.textButtonColors( - contentColor = colorScheme.primary, - ), - contentPadding = PaddingValues(0.dp) - ) { + fontWeight = FontWeight.SemiBold) + } + TextButton( + onClick = { + listAvailability = + listAvailability.toMutableList().apply { removeAt(index) } + }, + modifier = Modifier.wrapContentWidth().weight(0.15f), + shape = RoundedCornerShape(10.dp), + colors = + ButtonDefaults.textButtonColors( + contentColor = colorScheme.primary, + ), + contentPadding = PaddingValues(0.dp)) { Text( text = "Remove", style = poppinsTypography.labelSmall, - fontWeight = FontWeight.SemiBold - ) - } + fontWeight = FontWeight.SemiBold) + } } - } + } } Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) @@ -759,34 +702,27 @@ fun AnnouncementScreen( color = colorScheme.onBackground, fontWeight = FontWeight.Medium, modifier = - Modifier.padding( - start = 4.dp, bottom = 8.dp, top = 16.dp * heightRatio.value - ) - ) + Modifier.padding( + start = 4.dp, bottom = 8.dp, top = 16.dp * heightRatio.value)) if (uploadedImages.isEmpty()) { - Column( - modifier = - Modifier - .fillMaxWidth() - .height(100.dp * heightRatio.value) - .testTag( - "picturesButton" - ) // Tag for the upload pictures button scenario - .dashedBorder( - width = 1.5.dp, - brush = SolidColor(colorScheme.onSecondaryContainer), - shape = RoundedCornerShape(10.dp), - on = 7.dp, - off = 7.dp - ) - .background( - color = colorScheme.background, - shape = RoundedCornerShape(10.dp) - ), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column( + modifier = + Modifier.fillMaxWidth() + .height(100.dp * heightRatio.value) + .testTag( + "picturesButton") // Tag for the upload pictures button scenario + .dashedBorder( + width = 1.5.dp, + brush = SolidColor(colorScheme.onSecondaryContainer), + shape = RoundedCornerShape(10.dp), + on = 7.dp, + off = 7.dp) + .background( + color = colorScheme.background, + shape = RoundedCornerShape(10.dp)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { QuickFixButton( buttonText = "Upload Pictures", buttonColor = colorScheme.background, @@ -795,111 +731,92 @@ fun AnnouncementScreen( height = 50.dp, textColor = colorScheme.onBackground, textStyle = - poppinsTypography.labelSmall.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - ), + poppinsTypography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ), leadingIcon = Icons.Default.PhotoLibrary, - contentPadding = PaddingValues(0.dp) - ) - } + contentPadding = PaddingValues(0.dp)) + } } else { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(100.dp * heightRatio.value) - .padding(horizontal = 16.dp) - .testTag("uploadedImagesBox"), // Tag the uploaded images box - contentAlignment = Alignment.Center - ) { + Box( + modifier = + Modifier.fillMaxWidth() + .height(100.dp * heightRatio.value) + .padding(horizontal = 16.dp) + .testTag("uploadedImagesBox"), // Tag the uploaded images box + contentAlignment = Alignment.Center) { LazyRow( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = - Modifier - .fillMaxWidth() - .height(100.dp * heightRatio.value) - .testTag("uploadedImagesLazyRow") - ) { - val visibleImages = uploadedImages.take(3) - val remainingImageCount = uploadedImages.size - 3 + Modifier.fillMaxWidth() + .height(100.dp * heightRatio.value) + .testTag("uploadedImagesLazyRow")) { + val visibleImages = uploadedImages.take(3) + val remainingImageCount = uploadedImages.size - 3 - items(visibleImages.size) { index -> + items(visibleImages.size) { index -> Box( modifier = - Modifier - .padding(4.dp) - .size(90.dp) - .clip(RoundedCornerShape(8.dp)) - .testTag("uploadedImageCard$index") - ) { - Image( - painter = rememberAsyncImagePainter(visibleImages[index]), - contentDescription = "Image $index", - modifier = - Modifier - .fillMaxSize() - .testTag("uploadedImage$index"), - contentScale = ContentScale.Crop - ) - - if (index == 2 && remainingImageCount > 0) { + Modifier.padding(4.dp) + .size(90.dp) + .clip(RoundedCornerShape(8.dp)) + .testTag("uploadedImageCard$index")) { + Image( + painter = rememberAsyncImagePainter(visibleImages[index]), + contentDescription = "Image $index", + modifier = + Modifier.fillMaxSize().testTag("uploadedImage$index"), + contentScale = ContentScale.Crop) + + if (index == 2 && remainingImageCount > 0) { Box( modifier = - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.6f)) - .clickable { - navigationActions.navigateTo( - UserScreen.DISPLAY_UPLOADED_IMAGES - ) - } - .testTag("remainingImagesOverlay"), + Modifier.fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable { + navigationActions.navigateTo( + UserScreen.DISPLAY_UPLOADED_IMAGES) + } + .testTag("remainingImagesOverlay"), contentAlignment = Alignment.Center) { - Text( - text = "+$remainingImageCount", - color = Color.White, - style = MaterialTheme.typography.bodyLarge - ) - } - } - - IconButton( - onClick = { + Text( + text = "+$remainingImageCount", + color = Color.White, + style = MaterialTheme.typography.bodyLarge) + } + } + + IconButton( + onClick = { announcementViewModel.deleteUploadedImages( - listOf(visibleImages[index]) - ) - }, - modifier = - Modifier - .align(Alignment.TopEnd) - .padding(4.dp) - .size(24.dp) - .clip(CircleShape) - .testTag("deleteImageButton$index") - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Remove Image", - tint = Color.White, - modifier = - Modifier.background( - color = Color.Black.copy(alpha = 0.6f), - shape = CircleShape - ) - ) + listOf(visibleImages[index])) + }, + modifier = + Modifier.align(Alignment.TopEnd) + .padding(4.dp) + .size(24.dp) + .clip(CircleShape) + .testTag("deleteImageButton$index")) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Remove Image", + tint = Color.White, + modifier = + Modifier.background( + color = Color.Black.copy(alpha = 0.6f), + shape = CircleShape)) + } } - } + } } - } - } + } - Spacer(modifier = Modifier.height(8.dp)) - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth()) { QuickFixButton( buttonText = "Add more pictures", buttonColor = colorScheme.primary, @@ -907,18 +824,15 @@ fun AnnouncementScreen( height = 50.dp * heightRatio.value, textColor = colorScheme.onPrimary, textStyle = - poppinsTypography.labelSmall.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - ), - modifier = Modifier - .wrapContentWidth() - .padding(horizontal = 16.dp), + poppinsTypography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ), + modifier = Modifier.wrapContentWidth().padding(horizontal = 16.dp), leadingIcon = Icons.Default.PhotoLibrary, leadingIconTint = colorScheme.onPrimary, - contentPadding = PaddingValues(0.dp) - ) - } + contentPadding = PaddingValues(0.dp)) + } } Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) @@ -927,19 +841,16 @@ fun AnnouncementScreen( Text( text = "* Mandatory fields", color = - if (titleIsEmpty || - !categoryIsSelected || - !locationIsSelected || - descriptionIsEmpty - ) - colorScheme.error - else colorScheme.onSecondaryContainer, + if (titleIsEmpty || + !categoryIsSelected || + !locationIsSelected || + descriptionIsEmpty) + colorScheme.error + else colorScheme.onSecondaryContainer, style = - MaterialTheme.typography.bodySmall.copy( - fontSize = 10.sp, fontWeight = FontWeight.Medium - ), - modifier = Modifier.testTag("mandatoryText") - ) + MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, fontWeight = FontWeight.Medium), + modifier = Modifier.testTag("mandatoryText")) Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) @@ -947,55 +858,50 @@ fun AnnouncementScreen( QuickFixButton( buttonText = "Post your announcement", onClickAction = { - val announcementId = announcementViewModel.getNewUid() - val images = announcementViewModel.uploadedImages.value - - if (images.isEmpty()) { - handleSuccessfulImageUpload(announcementId, emptyList()) - } else { - announcementViewModel.uploadAnnouncementImages( - announcementId = announcementId, - images = images, - onSuccess = { uploadedImageUrls -> - handleSuccessfulImageUpload(announcementId, uploadedImageUrls) - }, - onFailure = { e -> - Log.e( - "AnnouncementViewModel", - "Failed to upload images: ${e.message}" - ) - }) - } + val announcementId = announcementViewModel.getNewUid() + val images = announcementViewModel.uploadedImages.value + + if (images.isEmpty()) { + handleSuccessfulImageUpload(announcementId, emptyList()) + } else { + announcementViewModel.uploadAnnouncementImages( + announcementId = announcementId, + images = images, + onSuccess = { uploadedImageUrls -> + handleSuccessfulImageUpload(announcementId, uploadedImageUrls) + }, + onFailure = { e -> + Log.e( + "AnnouncementViewModel", "Failed to upload images: ${e.message}") + }) + } }, buttonColor = colorScheme.primary, textColor = colorScheme.onPrimary, textStyle = - MaterialTheme.typography.titleMedium.copy( - fontSize = 16.sp, fontWeight = FontWeight.SemiBold - ), + MaterialTheme.typography.titleMedium.copy( + fontSize = 16.sp, fontWeight = FontWeight.SemiBold), modifier = - Modifier - .width(380.dp * widthRatio.value) - .height(50.dp * heightRatio.value) - .testTag("announcementButton"), + Modifier.width(380.dp * widthRatio.value) + .height(50.dp * heightRatio.value) + .testTag("announcementButton"), enabled = - !titleIsEmpty && + !titleIsEmpty && categoryIsSelected && locationIsSelected && - !descriptionIsEmpty - ) - } + !descriptionIsEmpty) + } } - QuickFixUploadImageSheet( - sheetState = sheetState, - showModalBottomSheet = showUploadImageSheet, - onDismissRequest = { showUploadImageSheet = false }, - onShowBottomSheetChange = { showUploadImageSheet = it }, - onActionRequest = { value -> announcementViewModel.addUploadedImage(value) }) - } + QuickFixUploadImageSheet( + sheetState = sheetState, + showModalBottomSheet = showUploadImageSheet, + onDismissRequest = { showUploadImageSheet = false }, + onShowBottomSheetChange = { showUploadImageSheet = it }, + onActionRequest = { value -> announcementViewModel.addUploadedImage(value) }) + } } fun millisToTimestamp(millis: Long): Timestamp { - return Timestamp(millis / 1000, ((millis % 1000) * 1000000).toInt()) + return Timestamp(millis / 1000, ((millis % 1000) * 1000000).toInt()) } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt index 41b266bf..e025f844 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt @@ -62,134 +62,116 @@ fun ExpandableCategoryItem( searchViewModel: SearchViewModel, navigationActions: NavigationActions ) { - val subCategories = remember { item.subcategories } - val interactionSource = remember { MutableInteractionSource() } - val rotationAngle by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f, label = "") + val subCategories = remember { item.subcategories } + val interactionSource = remember { MutableInteractionSource() } + val rotationAngle by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f, label = "") - Column( - modifier = - Modifier - .fillMaxWidth() - .shadow(5.dp, shape = RoundedCornerShape(8.dp), clip = false) - .background(color = colorScheme.surface, shape = RoundedCornerShape(12.dp)) - .clickable(interactionSource = interactionSource, indication = null) { + Column( + modifier = + Modifier.fillMaxWidth() + .shadow(5.dp, shape = RoundedCornerShape(8.dp), clip = false) + .background(color = colorScheme.surface, shape = RoundedCornerShape(12.dp)) + .clickable(interactionSource = interactionSource, indication = null) { onExpandedChange(!isExpanded) - } - .semantics { testTag = C.Tag.expandableCategoryItem }) { + } + .semantics { testTag = C.Tag.expandableCategoryItem }) { Row( modifier = - Modifier - .padding(start = 12.dp, top = 8.dp, bottom = 8.dp) - .background(backgroundColor), - verticalAlignment = Alignment.CenterVertically - ) { - // Icon - nameToIcon(item.name)?.let { + Modifier.padding(start = 12.dp, top = 8.dp, bottom = 8.dp) + .background(backgroundColor), + verticalAlignment = Alignment.CenterVertically) { + // Icon + nameToIcon(item.name)?.let { Icon( imageVector = it, contentDescription = null, tint = colorScheme.primary, - modifier = Modifier.testTag("categoryIcon") - ) - } + modifier = Modifier.testTag("categoryIcon")) + } - Spacer(modifier = Modifier.width(10.dp)) + Spacer(modifier = Modifier.width(10.dp)) - // Text Column - Column(modifier = Modifier.weight(7f)) { + // Text Column + Column(modifier = Modifier.weight(7f)) { Text( text = item.name, color = colorScheme.onBackground, fontWeight = FontWeight.SemiBold, fontFamily = poppinsFontFamily, - fontSize = 16.sp - ) + fontSize = 16.sp) Text( text = item.description, color = colorScheme.onSecondary, fontWeight = FontWeight.Medium, fontFamily = poppinsFontFamily, fontSize = 11.sp, - lineHeight = 16.sp - ) + lineHeight = 16.sp) + } + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.graphicsLayer(rotationZ = rotationAngle).weight(1f)) } - Icon( - imageVector = Icons.Filled.KeyboardArrowDown, - contentDescription = if (isExpanded) "Collapse" else "Expand", - modifier = Modifier - .graphicsLayer(rotationZ = rotationAngle) - .weight(1f) - ) - } AnimatedVisibility( visible = isExpanded, enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 3.dp) - .semantics { + exit = shrinkVertically() + fadeOut()) { + Column( + modifier = + Modifier.fillMaxWidth().padding(top = 3.dp).semantics { testTag = C.Tag.subCategories - }) { - subCategories.forEach { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 0.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = - Modifier - .weight(10f) - .semantics { - testTag = "${C.Tag.subCategoryName}_${it.name}" - } - .clickable { - searchViewModel.updateSearchQuery(it.name) - searchViewModel.setSearchSubcategory(it) - searchViewModel.filterWorkersBySubcategory(it.name) { - navigationActions.navigateTo( - UserScreen.SEARCH_WORKER_RESULT - ) - } - }, - text = it.name, - color = colorScheme.onSecondary, - fontWeight = FontWeight.Medium, - fontFamily = poppinsFontFamily, - fontSize = 11.sp, - lineHeight = 16.sp - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = if (isExpanded) "Collapse" else "Expand", - modifier = - Modifier - .weight(1f) - .clickable {} - .semantics { - testTag = "${C.Tag.enterSubCateIcon}_${it.name}" - }) - Spacer(modifier = Modifier.height(10.dp)) + }) { + subCategories.forEach { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 0.dp), + verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = + Modifier.weight(10f) + .semantics { + testTag = "${C.Tag.subCategoryName}_${it.name}" + } + .clickable { + searchViewModel.updateSearchQuery(it.name) + searchViewModel.setSearchSubcategory(it) + searchViewModel.filterWorkersBySubcategory(it.name) { + navigationActions.navigateTo( + UserScreen.SEARCH_WORKER_RESULT) + } + }, + text = it.name, + color = colorScheme.onSecondary, + fontWeight = FontWeight.Medium, + fontFamily = poppinsFontFamily, + fontSize = 11.sp, + lineHeight = 16.sp) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = + Modifier.weight(1f) + .clickable {} + .semantics { + testTag = "${C.Tag.enterSubCateIcon}_${it.name}" + }) + Spacer(modifier = Modifier.height(10.dp)) + } } - } + } } - } - } + } } private fun nameToIcon(displayName: String?): ImageVector? { - return when (displayName) { - "Painting" -> Icons.Outlined.ImagesearchRoller - "Plumbing" -> Icons.Outlined.Plumbing - "Gardening" -> Icons.Outlined.NaturePeople - "Electrical Work" -> Icons.Outlined.ElectricalServices - "Handyman Services" -> Icons.Outlined.Handyman - "Cleaning Services" -> Icons.Outlined.CleaningServices - "Carpentry" -> Icons.Outlined.Carpenter - "Moving Services" -> Icons.Outlined.LocalShipping - else -> null - } + return when (displayName) { + "Painting" -> Icons.Outlined.ImagesearchRoller + "Plumbing" -> Icons.Outlined.Plumbing + "Gardening" -> Icons.Outlined.NaturePeople + "Electrical Work" -> Icons.Outlined.ElectricalServices + "Handyman Services" -> Icons.Outlined.Handyman + "Cleaning Services" -> Icons.Outlined.CleaningServices + "Carpentry" -> Icons.Outlined.Carpenter + "Moving Services" -> Icons.Outlined.LocalShipping + else -> null + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt index 698d1e8a..f047b6da 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt @@ -48,9 +48,9 @@ import com.arygm.quickfix.model.profile.dataFields.Review import com.arygm.quickfix.model.search.AnnouncementViewModel import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.navigation.NavigationActions +import java.time.LocalTime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import java.time.LocalTime @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,187 +67,166 @@ fun QuickFixFinderScreen( viewModel(factory = AnnouncementViewModel.Factory), categoryViewModel: CategoryViewModel = viewModel(factory = CategoryViewModel.Factory) ) { - var isWindowVisible by remember { mutableStateOf(false) } + var isWindowVisible by remember { mutableStateOf(false) } - var pager by remember { mutableStateOf(true) } - var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerCategory by remember { mutableStateOf("Exterior Painter") } - var workerAddress by remember { mutableStateOf("Ecublens, VD") } - var description by remember { mutableStateOf("Worker description goes here.") } - var includedServices by remember { mutableStateOf(listOf()) } - var addonServices by remember { mutableStateOf(listOf()) } - var workerRating by remember { mutableDoubleStateOf(4.5) } - var tags by remember { mutableStateOf(listOf()) } - var reviews by remember { mutableStateOf(listOf()) } - val pagerState = rememberPagerState(pageCount = { 2 }) - val colorBackground = - if (pagerState.currentPage == 0) colorScheme.background else colorScheme.surface - val colorButton = - if (pagerState.currentPage == 1) colorScheme.background else colorScheme.surface + var pager by remember { mutableStateOf(true) } + var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } + var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } + var initialSaved by remember { mutableStateOf(false) } + var workerCategory by remember { mutableStateOf("Exterior Painter") } + var workerAddress by remember { mutableStateOf("Ecublens, VD") } + var description by remember { mutableStateOf("Worker description goes here.") } + var includedServices by remember { mutableStateOf(listOf()) } + var addonServices by remember { mutableStateOf(listOf()) } + var workerRating by remember { mutableDoubleStateOf(4.5) } + var tags by remember { mutableStateOf(listOf()) } + var reviews by remember { mutableStateOf(listOf()) } + val pagerState = rememberPagerState(pageCount = { 2 }) + val colorBackground = + if (pagerState.currentPage == 0) colorScheme.background else colorScheme.surface + val colorButton = if (pagerState.currentPage == 1) colorScheme.background else colorScheme.surface - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val screenHeight = maxHeight - val screenWidth = maxWidth + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenHeight = maxHeight + val screenWidth = maxWidth - Scaffold( - containerColor = colorBackground, - topBar = { - TopAppBar( - title = { - Text( - text = "Quickfix", - color = colorScheme.primary, - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier.testTag("QuickFixFinderTopBarTitle") - ) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = colorBackground), - modifier = Modifier.testTag("QuickFixFinderTopBar") - ) - }, - content = { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .testTag("QuickFixFinderContent") - .padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - val coroutineScope = rememberCoroutineScope() + Scaffold( + containerColor = colorBackground, + topBar = { + TopAppBar( + title = { + Text( + text = "Quickfix", + color = colorScheme.primary, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.testTag("QuickFixFinderTopBarTitle")) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = colorBackground), + modifier = Modifier.testTag("QuickFixFinderTopBar")) + }, + content = { padding -> + Column( + modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { + val coroutineScope = rememberCoroutineScope() - - if (pager) { - Surface( - color = colorButton, - shape = RoundedCornerShape(20.dp), - modifier = Modifier - .padding(horizontal = 40.dp) - .clip(RoundedCornerShape(20.dp)) - ) { - TabRow( - selectedTabIndex = pagerState.currentPage, - containerColor = Color.Transparent, - divider = {}, - indicator = {}, - modifier = - Modifier - .padding(horizontal = 1.dp, vertical = 1.dp) + if (pager) { + Surface( + color = colorButton, + shape = RoundedCornerShape(20.dp), + modifier = + Modifier.padding(horizontal = 40.dp).clip(RoundedCornerShape(20.dp))) { + TabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = Color.Transparent, + divider = {}, + indicator = {}, + modifier = + Modifier.padding(horizontal = 1.dp, vertical = 1.dp) .align(Alignment.CenterHorizontally) - .testTag("quickFixSearchTabRow") - ) { - QuickFixScreenTab(pagerState, coroutineScope, 0, "Search") - QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce") + .testTag("quickFixSearchTabRow")) { + QuickFixScreenTab(pagerState, coroutineScope, 0, "Search") + QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce") } - } - } + } + } - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - modifier = Modifier.testTag("quickFixSearchPager") - ) { page -> - when (page) { - 0 -> { - SearchOnBoarding( - onSearch = { pager = false }, - onSearchEmpty = { pager = true }, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel, - onProfileClick = { profiles -> - val profile = - WorkerProfile( - rating = 4.8, - fieldOfWork = "Exterior Painter", - description = "Worker description goes here.", - location = Location(12.0, 12.0, "Ecublens, VD"), - quickFixes = listOf("Painting", "Gardening"), - includedServices = - listOf( - IncludedService("Painting"), - IncludedService("Gardening"), - ), - addOnServices = + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.testTag("quickFixSearchPager")) { page -> + when (page) { + 0 -> { + SearchOnBoarding( + onSearch = { pager = false }, + onSearchEmpty = { pager = true }, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { profiles -> + val profile = + WorkerProfile( + rating = 4.8, + fieldOfWork = "Exterior Painter", + description = "Worker description goes here.", + location = Location(12.0, 12.0, "Ecublens, VD"), + quickFixes = listOf("Painting", "Gardening"), + includedServices = + listOf( + IncludedService("Painting"), + IncludedService("Gardening"), + ), + addOnServices = + listOf( + AddOnService("Furniture Assembly"), + AddOnService("Window Cleaning"), + ), + reviews = + ArrayDeque( listOf( - AddOnService("Furniture Assembly"), - AddOnService("Window Cleaning"), - ), - reviews = - ArrayDeque( - listOf( - Review("Bob", "nice work", 4.0), - Review("Alice", "bad work", 3.5), - ) - ), - profilePicture = "placeholder_worker", - price = 130.0, - displayName = "John Doe", - unavailability_list = emptyList(), - workingHours = Pair( - LocalTime.now(), - LocalTime.now() - ), - uid = "1234", - tags = listOf("Painter", "Gardener"), - ) + Review("Bob", "nice work", 4.0), + Review("Alice", "bad work", 3.5), + )), + profilePicture = "placeholder_worker", + price = 130.0, + displayName = "John Doe", + unavailability_list = emptyList(), + workingHours = Pair(LocalTime.now(), LocalTime.now()), + uid = "1234", + tags = listOf("Painter", "Gardener"), + ) - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerCategory = profile.fieldOfWork - workerAddress = profile.location?.name ?: "Unknown" - description = profile.description - includedServices = profile.includedServices.map { it.name } - addonServices = profile.addOnServices.map { it.name } - workerRating = profile.rating - tags = profile.tags - reviews = profile.reviews.map { it.review } + bannerImage = R.drawable.moroccan_flag + profilePicture = R.drawable.placeholder_worker + initialSaved = false + workerCategory = profile.fieldOfWork + workerAddress = profile.location?.name ?: "Unknown" + description = profile.description + includedServices = profile.includedServices.map { it.name } + addonServices = profile.addOnServices.map { it.name } + workerRating = profile.rating + tags = profile.tags + reviews = profile.reviews.map { it.review } - isWindowVisible = true - }) - } - - 1 -> { - AnnouncementScreen( - announcementViewModel, - loggedInAccountViewModel, - profileViewModel, - accountViewModel, - categoryViewModel, - navigationActions = navigationActions, - isUser = isUser - ) - - } - - else -> Text("Should never happen !") + isWindowVisible = true + }) + } + 1 -> { + AnnouncementScreen( + announcementViewModel, + loggedInAccountViewModel, + profileViewModel, + accountViewModel, + categoryViewModel, + navigationActions = navigationActions, + isUser = isUser) } + else -> Text("Should never happen !") + } } - } - }) - QuickFixSlidingWindowWorker( - isVisible = isWindowVisible, - onDismiss = { isWindowVisible = false }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = initialSaved, - workerCategory = workerCategory, - workerAddress = workerAddress, - description = description, - includedServices = includedServices, - addonServices = addonServices, - workerRating = workerRating, - tags = tags, - reviews = reviews, - screenHeight = screenHeight, - screenWidth = screenWidth, - onContinueClick = { /* Handle continue */ }) - } + } + }) + QuickFixSlidingWindowWorker( + isVisible = isWindowVisible, + onDismiss = { isWindowVisible = false }, + bannerImage = bannerImage, + profilePicture = profilePicture, + initialSaved = initialSaved, + workerCategory = workerCategory, + workerAddress = workerAddress, + description = description, + includedServices = includedServices, + addonServices = addonServices, + workerRating = workerRating, + tags = tags, + reviews = reviews, + screenHeight = screenHeight, + screenWidth = screenWidth, + onContinueClick = { /* Handle continue */}) + } } @Composable @@ -257,29 +236,23 @@ fun QuickFixScreenTab( currentPage: Int, title: String ) { - Tab( - selected = pagerState.currentPage == currentPage, - onClick = { coroutineScope.launch { pagerState.scrollToPage(currentPage) } }, - modifier = - Modifier - .padding(horizontal = 4.dp, vertical = 4.dp) - .clip(RoundedCornerShape(13.dp)) - .background( - if (pagerState.currentPage == currentPage) colorScheme.primary - else Color.Transparent - ) - .testTag("tab$title") - ) { + Tab( + selected = pagerState.currentPage == currentPage, + onClick = { coroutineScope.launch { pagerState.scrollToPage(currentPage) } }, + modifier = + Modifier.padding(horizontal = 4.dp, vertical = 4.dp) + .clip(RoundedCornerShape(13.dp)) + .background( + if (pagerState.currentPage == currentPage) colorScheme.primary + else Color.Transparent) + .testTag("tab$title")) { Text( title, color = - if (pagerState.currentPage == currentPage) colorScheme.background - else colorScheme.tertiaryContainer, + if (pagerState.currentPage == currentPage) colorScheme.background + else colorScheme.tertiaryContainer, style = MaterialTheme.typography.titleMedium, modifier = - Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .testTag("tabText$title") - ) - } + Modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("tabText$title")) + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index dff64473..31acce98 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -84,6 +84,7 @@ fun SearchWorkerResult( var showServicesBottomSheet by remember { mutableStateOf(false) } var showPriceRangeBottomSheet by remember { mutableStateOf(false) } var showLocationBottomSheet by remember { mutableStateOf(false) } + var selectedLocationIndex by remember { mutableStateOf(null) } var isWindowVisible by remember { mutableStateOf(false) } var saved by remember { mutableStateOf(false) } @@ -288,8 +289,14 @@ fun SearchWorkerResult( filterState.selectedDays = days filterState.selectedHour = hour filterState.selectedMinute = minute + if (filterState.availabilityFilterApplied) { + updateFilteredProfiles() + } else { + filteredWorkerProfiles = + searchViewModel.filterWorkersByAvailability( + filteredWorkerProfiles, days, hour, minute) + } filterState.availabilityFilterApplied = true - updateFilteredProfiles() }, onClearClick = { filterState.availabilityFilterApplied = false @@ -340,7 +347,10 @@ fun SearchWorkerResult( showLocationBottomSheet, userProfile = userProfile, phoneLocation = filterState.phoneLocation, + selectedLocationIndex = selectedLocationIndex, onApplyClick = { location, max -> + selectedLocationIndex = userProfile.locations.indexOf(location) + 1 + filterState.selectedLocation = location if (location == Location(0.0, 0.0, "Default")) { Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() @@ -357,6 +367,7 @@ fun SearchWorkerResult( filterState.maxDistance = 0 filterState.locationFilterApplied = false updateFilteredProfiles() + selectedLocationIndex = null }, clearEnabled = filterState.locationFilterApplied) From ab48b645ebd689a7e55f90a88c432e16eef8a253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Sat, 14 Dec 2024 14:44:18 +0100 Subject: [PATCH 07/15] Test: add tests to reach 80% coverage --- .../QuickFixFinderUserNoModeScreenTest.kt | 26 +++++++++++++++++++ .../ui/search/SearchOnBoardingTest.kt | 22 ++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt index 95849332..896bb157 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt @@ -155,4 +155,30 @@ class QuickFixFinderUserNoModeScreenTest { // value assertEquals(1, getBottomBarIdUser(UserRoute.HOME)) } + + @Test + fun tabSelectionUpdatesUIStateCorrectly() { + composeTestRule.setContent { + QuickFixFinderScreen( + navigationActions, + navigationActionsRoot, + isUser = true, + searchViewModel = searchViewModel) + } + + // Verify initial state + composeTestRule.onNodeWithTag("tabSearch").assertExists().assertIsDisplayed() + + // Switch to Announce tab + composeTestRule.onNodeWithTag("tabAnnounce").performClick() + + // Verify AnnouncementScreen is displayed + composeTestRule.onNodeWithTag("AnnouncementContent").assertIsDisplayed() + + // Switch back to Search tab + composeTestRule.onNodeWithTag("tabSearch").performClick() + + // Verify SearchOnBoarding is displayed + composeTestRule.onNodeWithTag("searchContent").assertExists() + } } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt index 1cae00a9..12cc8193 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt @@ -126,4 +126,26 @@ class SearchOnBoardingTest { // Verify state after query input (Categories disappear, Profiles appear) composeTestRule.onNodeWithText("Categories").assertDoesNotExist() } + + @Test + fun searchOnBoarding_showsFilterButtonsWhenQueryIsNotEmpty() { + composeTestRule.setContent { + SearchOnBoarding( + onSearch = {}, + onSearchEmpty = {}, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) + } + + // Input text to simulate non-empty search + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + + // Verify that the filter row becomes visible + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() + } } From de5971d8a718f1fee0175ab58b0e8534842f7e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Sat, 14 Dec 2024 16:42:47 +0100 Subject: [PATCH 08/15] Style: cleanup --- .../userModeUI/search/QuickFixFinder.kt | 34 +++++++++++-------- .../userModeUI/search/SearchFilters.kt | 7 ++-- .../userModeUI/search/SearchOnBoarding.kt | 4 ++- .../userModeUI/search/SearchWorkerResult.kt | 33 ++++++------------ 4 files changed, 38 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt index f047b6da..e83a7336 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp import androidx.lifecycle.viewmodel.compose.viewModel import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel @@ -68,7 +68,6 @@ fun QuickFixFinderScreen( categoryViewModel: CategoryViewModel = viewModel(factory = CategoryViewModel.Factory) ) { var isWindowVisible by remember { mutableStateOf(false) } - var pager by remember { mutableStateOf(true) } var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } @@ -114,20 +113,25 @@ fun QuickFixFinderScreen( if (pager) { Surface( color = colorButton, - shape = RoundedCornerShape(20.dp), + shape = RoundedCornerShape(screenWidth * 0.05f), modifier = - Modifier.padding(horizontal = 40.dp).clip(RoundedCornerShape(20.dp))) { + Modifier.padding(horizontal = screenWidth * 0.1f) + .clip(RoundedCornerShape(screenWidth * 0.05f))) { TabRow( selectedTabIndex = pagerState.currentPage, containerColor = Color.Transparent, divider = {}, indicator = {}, modifier = - Modifier.padding(horizontal = 1.dp, vertical = 1.dp) + Modifier.padding( + horizontal = screenWidth * 0.0025f, + vertical = screenWidth * 0.0025f) .align(Alignment.CenterHorizontally) .testTag("quickFixSearchTabRow")) { - QuickFixScreenTab(pagerState, coroutineScope, 0, "Search") - QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce") + QuickFixScreenTab( + pagerState, coroutineScope, 0, "Search", screenWidth) + QuickFixScreenTab( + pagerState, coroutineScope, 1, "Announce", screenWidth) } } } @@ -145,8 +149,7 @@ fun QuickFixFinderScreen( navigationActionsRoot, searchViewModel, accountViewModel, - categoryViewModel, - onProfileClick = { profiles -> + categoryViewModel) { profiles -> val profile = WorkerProfile( rating = 4.8, @@ -192,7 +195,7 @@ fun QuickFixFinderScreen( reviews = profile.reviews.map { it.review } isWindowVisible = true - }) + } } 1 -> { AnnouncementScreen( @@ -209,6 +212,7 @@ fun QuickFixFinderScreen( } } }) + QuickFixSlidingWindowWorker( isVisible = isWindowVisible, onDismiss = { isWindowVisible = false }, @@ -234,14 +238,15 @@ fun QuickFixScreenTab( pagerState: PagerState, coroutineScope: CoroutineScope, currentPage: Int, - title: String + title: String, + screenWidth: Dp ) { Tab( selected = pagerState.currentPage == currentPage, onClick = { coroutineScope.launch { pagerState.scrollToPage(currentPage) } }, modifier = - Modifier.padding(horizontal = 4.dp, vertical = 4.dp) - .clip(RoundedCornerShape(13.dp)) + Modifier.padding(horizontal = screenWidth * 0.01f, vertical = screenWidth * 0.01f) + .clip(RoundedCornerShape(screenWidth * 0.0325f)) .background( if (pagerState.currentPage == currentPage) colorScheme.primary else Color.Transparent) @@ -253,6 +258,7 @@ fun QuickFixScreenTab( else colorScheme.tertiaryContainer, style = MaterialTheme.typography.titleMedium, modifier = - Modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("tabText$title")) + Modifier.padding(horizontal = screenWidth * 0.04f, vertical = screenWidth * 0.02f) + .testTag("tabText$title")) } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt index 45503043..4afeb2c9 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.WorkerProfile @@ -182,10 +183,10 @@ fun FilterRow( showFilterButtons: Boolean, toggleFilterButtons: () -> Unit, listOfButtons: List, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + screenWidth: Dp, + screenHeight: Dp ) { - val screenHeight = 800.dp // These could be replaced with actual dimension calculations - val screenWidth = 400.dp IconButton( onClick = { toggleFilterButtons() }, diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index cf14ff8c..d5795a3f 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -203,7 +203,9 @@ fun SearchOnBoarding( showFilterButtons = showFilterButtons, toggleFilterButtons = { showFilterButtons = !showFilterButtons }, listOfButtons = listOfButtons, - modifier = Modifier.padding(bottom = screenHeight * 0.01f)) + modifier = Modifier.padding(bottom = screenHeight * 0.01f), + screenWidth = screenWidth, + screenHeight = screenHeight) } ProfileResults( diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index 31acce98..7269cd94 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -60,9 +60,7 @@ import com.arygm.quickfix.utils.LocationHelper import com.arygm.quickfix.utils.loadUserId import java.time.LocalTime -@OptIn( - ExperimentalMaterial3Api::class, -) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchWorkerResult( navigationActions: NavigationActions, @@ -74,6 +72,7 @@ fun SearchWorkerResult( val context = LocalContext.current val locationHelper = LocationHelper(context, MainActivity()) + // State that manages all filters and their applied logic val filterState = rememberSearchFiltersState() val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() @@ -87,7 +86,6 @@ fun SearchWorkerResult( var selectedLocationIndex by remember { mutableStateOf(null) } var isWindowVisible by remember { mutableStateOf(false) } - var saved by remember { mutableStateOf(false) } var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } @@ -101,14 +99,13 @@ fun SearchWorkerResult( var tags by remember { mutableStateOf(listOf()) } var reviews by remember { mutableStateOf(listOf()) } - // User and location setup var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") var uid by remember { mutableStateOf("Loading...") } val searchQuery by searchViewModel.searchQuery.collectAsState() val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() - // Fetch user id and profile + // Fetch user and set base location LaunchedEffect(Unit) { uid = loadUserId(preferencesViewModel) userProfileViewModel.fetchUserProfile(uid) { profile -> @@ -120,7 +117,6 @@ fun SearchWorkerResult( } } - // Location initialization LaunchedEffect(Unit) { if (locationHelper.checkPermissions()) { locationHelper.getCurrentLocation { location -> @@ -139,6 +135,7 @@ fun SearchWorkerResult( val listState = rememberLazyListState() + // Update the displayed profiles after filters have changed fun updateFilteredProfiles() { filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) } @@ -174,7 +171,7 @@ fun SearchWorkerResult( } }, actions = { - IconButton(onClick = { /* Handle search */}) { + IconButton(onClick = {}) { Icon( imageVector = Icons.Default.Search, contentDescription = "Search", @@ -221,7 +218,9 @@ fun SearchWorkerResult( showFilterButtons = showFilterButtons, toggleFilterButtons = { showFilterButtons = !showFilterButtons }, listOfButtons = listOfButtons, - modifier = Modifier.padding(bottom = screenHeight * 0.01f)) + modifier = Modifier.padding(bottom = screenHeight * 0.01f), + screenWidth = screenWidth, + screenHeight = screenHeight) } ProfileResults( @@ -231,7 +230,7 @@ fun SearchWorkerResult( searchViewModel = searchViewModel, accountViewModel = accountViewModel, onBookClick = { selectedProfile -> - // Mock data for demonstration, replace with actual data + // Mock data for demonstration val profile = WorkerProfile( rating = 4.8, @@ -289,14 +288,8 @@ fun SearchWorkerResult( filterState.selectedDays = days filterState.selectedHour = hour filterState.selectedMinute = minute - if (filterState.availabilityFilterApplied) { - updateFilteredProfiles() - } else { - filteredWorkerProfiles = - searchViewModel.filterWorkersByAvailability( - filteredWorkerProfiles, days, hour, minute) - } filterState.availabilityFilterApplied = true + updateFilteredProfiles() }, onClearClick = { filterState.availabilityFilterApplied = false @@ -350,11 +343,7 @@ fun SearchWorkerResult( selectedLocationIndex = selectedLocationIndex, onApplyClick = { location, max -> selectedLocationIndex = userProfile.locations.indexOf(location) + 1 - filterState.selectedLocation = location - if (location == Location(0.0, 0.0, "Default")) { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() - } filterState.baseLocation = location filterState.maxDistance = max filterState.locationFilterApplied = true @@ -376,7 +365,7 @@ fun SearchWorkerResult( onDismiss = { isWindowVisible = false }, bannerImage = bannerImage, profilePicture = profilePicture, - initialSaved = saved, + initialSaved = initialSaved, workerCategory = workerCategory, workerAddress = workerAddress, description = description, From 513da1fc65b4873e1dfcc00a68978f354e421582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Sat, 14 Dec 2024 18:27:04 +0100 Subject: [PATCH 09/15] Test: add tests for coverage --- .../QuickFixFinderUserNoModeScreenTest.kt | 47 +++ .../ui/search/SearchOnBoardingTest.kt | 267 ++++++++++++++++++ .../userModeUI/search/SearchOnBoarding.kt | 38 ++- 3 files changed, 332 insertions(+), 20 deletions(-) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt index 896bb157..97ee1655 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderUserNoModeScreenTest.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import com.arygm.quickfix.model.category.CategoryRepositoryFirestore import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.search.SearchViewModel @@ -181,4 +182,50 @@ class QuickFixFinderUserNoModeScreenTest { // Verify SearchOnBoarding is displayed composeTestRule.onNodeWithTag("searchContent").assertExists() } + + @Test + fun quickFixFinderScreen_searchHidesPagerAndTabRow() { + composeTestRule.setContent { + QuickFixFinderScreen( + navigationActions, + navigationActionsRoot, + isUser = true, + searchViewModel = searchViewModel) + } + + // Initially, pager and tabs are visible (Search tab is shown) + composeTestRule.onNodeWithTag("quickFixSearchTabRow").assertIsDisplayed() + composeTestRule.onNodeWithTag("searchContent").assertIsDisplayed() + + // Perform a search that triggers onSearch callback and sets pager = false + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + + // After performing a search, pager is false, so TabRow should no longer be visible + // Since we know that onSearch sets pager to false, we check if tab row disappears + composeTestRule.onNodeWithTag("quickFixSearchTabRow").assertDoesNotExist() + } + + @Test + fun quickFixFinderScreen_emptySearchShowsPagerAndTabRow() { + composeTestRule.setContent { + QuickFixFinderScreen( + navigationActions, + navigationActionsRoot, + isUser = true, + searchViewModel = searchViewModel) + } + + // Perform a search first + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + composeTestRule.waitForIdle() + // Tab row should be hidden now + composeTestRule.onNodeWithTag("quickFixSearchTabRow").assertDoesNotExist() + + // Clear the search input to trigger onSearchEmpty, setting pager = true again + composeTestRule.onNodeWithTag("clearSearchIcon", useUnmergedTree = true).performClick() + composeTestRule.waitForIdle() + + // Now tab row should be visible again + composeTestRule.onNodeWithTag("quickFixSearchTabRow").assertIsDisplayed() + } } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt index 12cc8193..9ea5e1dd 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt @@ -5,26 +5,41 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasParent +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.printToLog import androidx.compose.ui.text.AnnotatedString import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryRepositoryFirestore import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.category.Subcategory +import com.arygm.quickfix.model.locations.Location +import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ressources.C import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchOnBoarding +import com.arygm.quickfix.ui.userModeUI.navigation.UserTopLevelDestinations import io.mockk.mockk +import java.time.LocalDate +import java.time.LocalTime import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito.mock +import org.mockito.Mockito.verify class SearchOnBoardingTest { @@ -145,7 +160,259 @@ class SearchOnBoardingTest { // Input text to simulate non-empty search composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + composeTestRule.onNodeWithTag("tuneButton").performClick() + // Verify that the filter row becomes visible composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() } + + @Test + fun testOpenAvailabilityFilterMenu() { + // Set up test worker profiles + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + unavailability_list = listOf(LocalDate.now()), + location = Location(40.7128, -74.0060)) + + val worker2 = + WorkerProfile( + uid = "worker2", + fieldOfWork = "Electrician", + rating = 4.0, + workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + // Update the searchViewModel with test workers + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2) + + // Set the composable content + composeTestRule.setContent { + SearchOnBoarding( + onSearch = {}, + onSearchEmpty = {}, + navigationActions = navigationActions, + navigationActionsRoot = navigationActionsRoot, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel, + onProfileClick = { _ -> }) + } + + // Wait for the UI to settle + composeTestRule.waitForIdle() + + // Perform a search query to display filter buttons + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed().performScrollToIndex(3) + + // Scroll to the "Availability" filter button + + // Click the "Availability" filter button + composeTestRule.onNodeWithText("Availability").performClick() + + // Wait for the availability bottom sheet to appear + composeTestRule.waitForIdle() + + // Verify the bottom sheet and its components are displayed + composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() + composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() + composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() + composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() + + val today = LocalDate.now() + val day = today.dayOfMonth + + val textFields = + composeTestRule.onAllNodes(hasSetTextAction()).filter(hasParent(hasTestTag("timeInput"))) + + // Ensure that we have at least two text fields + assert(textFields.fetchSemanticsNodes().size >= 2) + + // Set the hour to "07" + textFields[0].performTextReplacement("08") + + // Set the minute to "00" + textFields[1].performTextReplacement("00") + + // Find the node representing today's date and perform a click + composeTestRule + .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) + .performClick() + + composeTestRule.onNodeWithText("OK").performClick() + + composeTestRule.waitForIdle() + + // Verify that no workers are displayed + } + + @Test + fun testOpenPriceRangeFilterMenu() { + // Set up test worker profiles + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + location = Location(40.7128, -74.0060)) + + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1) + + // Set the composable content + composeTestRule.setContent { + SearchOnBoarding( + onSearch = {}, + onSearchEmpty = {}, + navigationActions = navigationActions, + navigationActionsRoot = navigationActionsRoot, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel, + onProfileClick = { _ -> }) + } + + // Perform a search query to display filter buttons + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed().performScrollToIndex(5) + + // Click on "Price Range" filter button + composeTestRule.onNodeWithText("Price Range").performClick() + + // Verify the bottom sheet appears + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("priceRangeModalSheet").assertIsDisplayed() + } + + @Test + fun testOpenLocationFilterMenu() { + // Set up test worker profiles + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + location = Location(40.7128, -74.0060)) + + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1) + + // Set the composable content + composeTestRule.setContent { + SearchOnBoarding( + onSearch = {}, + onSearchEmpty = {}, + navigationActions = navigationActions, + navigationActionsRoot = navigationActionsRoot, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel, + onProfileClick = { _ -> }) + } + + // Perform a search query to display filter buttons + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() + + // Click on "Location" filter button + composeTestRule.onNodeWithText("Location").performClick() + + // Verify the bottom sheet appears + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() + } + + @Test + fun searchOnBoarding_cancelButtonNavigatesHome() { + composeTestRule.setContent { + SearchOnBoarding( + onSearch = {}, + onSearchEmpty = {}, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) + } + + // Click the Cancel button + composeTestRule.onNodeWithText("Cancel").performClick() + + // Verify navigation to HOME was requested + verify(navigationActionsRoot).navigateTo(UserTopLevelDestinations.HOME) + } + + @Test + fun searchOnBoarding_servicesFilterApplyAndClear() { + // Set up some mock services in searchViewModel + searchViewModel._searchSubcategory.value = + Subcategory(name = "TestSubCategory", tags = listOf("Service1", "Service2")) + + composeTestRule.setContent { + SearchOnBoarding( + onSearch = {}, + onSearchEmpty = {}, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) + } + + // Perform a search to show filters + composeTestRule.onNodeWithTag("searchContent").performTextInput("Test") + composeTestRule.onNodeWithTag("tuneButton").performClick() + + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + // Open services bottom sheet + composeTestRule.onNodeWithText("Service Type").performClick() + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertIsDisplayed() + + // Simulate selecting "Interior Painter" + composeTestRule.onNodeWithText("Apply").performClick() + } + + @Test + fun searchOnBoarding_priceRangeFilterApplyAndClear() { + composeTestRule.setContent { + SearchOnBoarding( + onSearch = {}, + onSearchEmpty = {}, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) + } + + // Perform a search to show filters + composeTestRule.onNodeWithTag("searchContent").performTextInput("Tester") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) + composeTestRule.onNodeWithText("Price Range").performClick() + + // Input a price range and apply + composeTestRule.onNodeWithText("Apply").performClick() + + // Open price range again and clear + composeTestRule.onNodeWithText("Price Range").performClick() + composeTestRule.onNodeWithText("Clear").performClick() + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index d5795a3f..01a4ec87 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -131,6 +131,7 @@ fun SearchOnBoarding( imageVector = Icons.Filled.Clear, contentDescription = "Clear search query", tint = colorScheme.onBackground, + modifier = Modifier.testTag("clearSearchIcon"), ) }, placeHolderText = "Find your perfect fix with QuickFix", @@ -195,8 +196,7 @@ fun SearchOnBoarding( modifier = Modifier.fillMaxWidth() .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) - .padding(horizontal = screenWidth * 0.02f) - .testTag("filter_buttons_row"), + .padding(horizontal = screenWidth * 0.02f), verticalAlignment = Alignment.CenterVertically, ) { FilterRow( @@ -242,24 +242,22 @@ fun SearchOnBoarding( }, clearEnabled = filterState.availabilityFilterApplied) - searchSubcategory?.let { - ChooseServiceTypeSheet( - showServicesBottomSheet, - it.tags, - selectedServices = filterState.selectedServices, - onApplyClick = { services -> - filterState.selectedServices = services - filterState.servicesFilterApplied = true - updateFilteredProfiles() - }, - onDismissRequest = { showServicesBottomSheet = false }, - onClearClick = { - filterState.selectedServices = emptyList() - filterState.servicesFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = filterState.servicesFilterApplied) - } + ChooseServiceTypeSheet( + showServicesBottomSheet, + emptyList(), + selectedServices = filterState.selectedServices, + onApplyClick = { services -> + filterState.selectedServices = services + filterState.servicesFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { showServicesBottomSheet = false }, + onClearClick = { + filterState.selectedServices = emptyList() + filterState.servicesFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.servicesFilterApplied) QuickFixPriceRangeBottomSheet( showPriceRangeBottomSheet, From 761f36793f04a9068cb6865b369039704d36d0c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Thu, 19 Dec 2024 03:14:57 +0100 Subject: [PATCH 10/15] Merge: merge finished and tests compiling --- .../AnnouncementUserNoModeScreenTest.kt | 1 - .../quickfix/ui/search/ProfileResultsTest.kt | 4 +- .../search/QuickFixSlidingWindowWorkerTest.kt | 79 +- .../ui/search/SearchOnBoardingTest.kt | 721 ++-- .../ui/search/SearchWorkerResultScreenTest.kt | 3657 ++++++++--------- .../uiMode/appContentUI/AppContentNavGraph.kt | 118 +- .../userModeUI/UserModeNavGraph.kt | 5 +- .../userModeUI/search/Announcement.kt | 1222 +++--- .../userModeUI/search/ProfileResults.kt | 127 +- .../userModeUI/search/QuickFixFinder.kt | 286 +- .../search/QuickFixSlidingWindowWorker.kt | 349 +- .../userModeUI/search/SearchFilters.kt | 71 +- .../userModeUI/search/SearchOnBoarding.kt | 175 +- .../userModeUI/search/SearchWorkerResult.kt | 380 +- 14 files changed, 3190 insertions(+), 4005 deletions(-) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementUserNoModeScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementUserNoModeScreenTest.kt index 67543b0c..34630fdf 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementUserNoModeScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementUserNoModeScreenTest.kt @@ -20,7 +20,6 @@ import com.arygm.quickfix.model.profile.ProfileRepository import com.arygm.quickfix.model.search.AnnouncementRepository import com.arygm.quickfix.model.search.AnnouncementViewModel import com.arygm.quickfix.ui.navigation.NavigationActions -import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.AnnouncementScreen import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScreen import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.AnnouncementScreen import java.time.LocalDate diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt index 2bfa074e..4f2b4821 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt @@ -10,6 +10,7 @@ import com.arygm.quickfix.model.account.AccountRepositoryFirestore import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryRepositoryFirestore import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.search.SearchViewModel @@ -103,7 +104,8 @@ class ProfileResultsTest { listState = rememberLazyListState(), searchViewModel = searchViewModel, accountViewModel = accountViewModel, - onBookClick = { _, _ -> }) + onBookClick = { _ -> }, + baseLocation = mock(Location::class.java)) } // Allow coroutines to complete diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt index 0a037583..2b9956f7 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt @@ -5,7 +5,10 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.unit.dp -import com.arygm.quickfix.R +import com.arygm.quickfix.model.profile.WorkerProfile +import com.arygm.quickfix.model.profile.dataFields.AddOnService +import com.arygm.quickfix.model.profile.dataFields.IncludedService +import com.arygm.quickfix.model.profile.dataFields.Review import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.QuickFixSlidingWindowWorker import org.junit.Rule import org.junit.Test @@ -19,38 +22,39 @@ class QuickFixSlidingWindowWorkerTest { // Mock data val includedServices = listOf( - "Initial Consultation", - "Basic Surface Preparation", - "Priming of Surfaces", - "High-Quality Paint Application", - "Two Coats of Paint", - "Professional Cleanup") + "Initial Consultation", + "Basic Surface Preparation", + "Priming of Surfaces", + "High-Quality Paint Application", + "Two Coats of Paint", + "Professional Cleanup") + .map { IncludedService(it) } val addonServices = listOf( - "Detailed Color Consultation", "Premium Paint Upgrade", "Extensive Surface Preparation") + "Detailed Color Consultation", + "Premium Paint Upgrade", + "Extensive Surface Preparation") + .map { AddOnService(it) } val reviews = - listOf("Great service!", "Very professional and clean.", "Would highly recommend.") + ArrayDeque( + listOf("Great service!", "Very professional and clean.", "Would highly recommend.") + .map { Review("bob", it, 4.0) }) composeTestRule.setContent { QuickFixSlidingWindowWorker( isVisible = true, onDismiss = { /* No-op */}, - bannerImage = R.drawable.moroccan_flag, - profilePicture = R.drawable.placeholder_worker, - initialSaved = false, - workerCategory = "Painter", - workerAddress = "123 Main Street", - description = "Sample description for the worker.", - includedServices = includedServices, - addonServices = addonServices, - workerRating = 4.5, - tags = listOf("Exterior Painting", "Interior Painting"), - reviews = reviews, screenHeight = 800.dp, screenWidth = 400.dp, - onContinueClick = { /* No-op */}) + onContinueClick = { /* No-op */}, + workerProfile = + WorkerProfile( + includedServices = includedServices, + addOnServices = addonServices, + reviews = reviews), + ) } // Verify the included services section is displayed @@ -92,17 +96,11 @@ class QuickFixSlidingWindowWorkerTest { QuickFixSlidingWindowWorker( isVisible = true, onDismiss = { /* No-op */}, - bannerImage = R.drawable.moroccan_flag, - profilePicture = R.drawable.placeholder_worker, - initialSaved = false, - workerCategory = "Painter", - workerAddress = "123 Main Street", - description = "Sample description for the worker.", - includedServices = includedServices, - addonServices = addOnServices, - workerRating = 4.5, - tags = listOf("Exterior Painting", "Interior Painting"), - reviews = reviews, + workerProfile = + WorkerProfile( + includedServices = includedServices.map { IncludedService(it) }, + addOnServices = addOnServices.map { AddOnService(it) }, + reviews = ArrayDeque(reviews.map { Review("bob", it, 4.0) })), screenHeight = 800.dp, screenWidth = 400.dp, onContinueClick = { /* No-op */}) @@ -155,17 +153,12 @@ class QuickFixSlidingWindowWorkerTest { QuickFixSlidingWindowWorker( isVisible = true, onDismiss = { /* No-op */}, - bannerImage = R.drawable.moroccan_flag, - profilePicture = R.drawable.placeholder_worker, - initialSaved = false, - workerCategory = "Painter", - workerAddress = "123 Main Street", - description = "Sample description for the worker.", - includedServices = includedServices, - addonServices = addOnServices, - workerRating = 4.5, - tags = tags, - reviews = reviews, + workerProfile = + WorkerProfile( + includedServices = includedServices.map { IncludedService(it) }, + addOnServices = addOnServices.map { AddOnService(it) }, + tags = tags, + reviews = ArrayDeque(reviews.map { Review("bob", it, 4.0) })), screenHeight = 800.dp, screenWidth = 400.dp, onContinueClick = { /* No-op */}) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt index 14cc48b3..c41beaca 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt @@ -35,412 +35,373 @@ import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserTopLevelDestinations import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchOnBoarding import io.mockk.mockk +import java.time.LocalDate +import java.time.LocalTime import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito.mock import org.mockito.Mockito.verify -import java.time.LocalDate -import java.time.LocalTime class SearchOnBoardingTest { - private lateinit var navigationActions: NavigationActions - private lateinit var workerProfileRepo: WorkerProfileRepositoryFirestore - private lateinit var accountRepositoryFirestore: AccountRepositoryFirestore - private lateinit var categoryRepo: CategoryRepositoryFirestore - private lateinit var searchViewModel: SearchViewModel - private lateinit var accountViewModel: AccountViewModel - private lateinit var categoryViewModel: CategoryViewModel - private lateinit var navigationActionsRoot: NavigationActions - private lateinit var quickFixViewModel: QuickFixViewModel - - @get:Rule - val composeTestRule = createComposeRule() - - @Before - fun setup() { - navigationActions = mock(NavigationActions::class.java) - navigationActionsRoot = mock(NavigationActions::class.java) - workerProfileRepo = mockk(relaxed = true) - categoryRepo = mockk(relaxed = true) - accountRepositoryFirestore = mock(AccountRepositoryFirestore::class.java) - searchViewModel = SearchViewModel(workerProfileRepo) - categoryViewModel = CategoryViewModel(categoryRepo) - accountViewModel = mockk(relaxed = true) - quickFixViewModel = QuickFixViewModel(mock()) + private lateinit var navigationActions: NavigationActions + private lateinit var workerProfileRepo: WorkerProfileRepositoryFirestore + private lateinit var accountRepositoryFirestore: AccountRepositoryFirestore + private lateinit var categoryRepo: CategoryRepositoryFirestore + private lateinit var searchViewModel: SearchViewModel + private lateinit var accountViewModel: AccountViewModel + private lateinit var categoryViewModel: CategoryViewModel + private lateinit var navigationActionsRoot: NavigationActions + private lateinit var quickFixViewModel: QuickFixViewModel + + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun setup() { + navigationActions = mock(NavigationActions::class.java) + navigationActionsRoot = mock(NavigationActions::class.java) + workerProfileRepo = mockk(relaxed = true) + categoryRepo = mockk(relaxed = true) + accountRepositoryFirestore = mock(AccountRepositoryFirestore::class.java) + searchViewModel = SearchViewModel(workerProfileRepo) + categoryViewModel = CategoryViewModel(categoryRepo) + accountViewModel = mockk(relaxed = true) + quickFixViewModel = QuickFixViewModel(mock()) + } + + @Test + fun searchOnBoarding_displaysSearchInput() { + composeTestRule.setContent { + SearchOnBoarding( + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) } - @Test - fun searchOnBoarding_displaysSearchInput() { - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Check that the search input field is displayed - composeTestRule.onNodeWithTag("searchContent").assertIsDisplayed() - - // Enter some text and check if the trailing clear icon appears - composeTestRule.onNodeWithTag("searchContent").performTextInput("plumbing") - composeTestRule.onNodeWithTag(C.Tag.clear_button_text_field_custom).assertIsDisplayed() + // Check that the search input field is displayed + composeTestRule.onNodeWithTag("searchContent").assertIsDisplayed() + + // Enter some text and check if the trailing clear icon appears + composeTestRule.onNodeWithTag("searchContent").performTextInput("plumbing") + composeTestRule.onNodeWithTag(C.Tag.clear_button_text_field_custom).assertIsDisplayed() + } + + @Test + fun searchOnBoarding_clearsTextOnTrailingIconClick() { + composeTestRule.setContent { + SearchOnBoarding( + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) } - @Test - fun searchOnBoarding_clearsTextOnTrailingIconClick() { - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Input text into the search field - val searchInput = composeTestRule.onNodeWithTag("searchContent") - searchInput.performTextInput("electrician") - searchInput.assertTextEquals("electrician") // Verify text input - - // Click the trailing icon (clear button) - composeTestRule.onNodeWithTag(C.Tag.clear_button_text_field_custom).performClick() - - // Wait for UI to settle - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag(C.Tag.clear_button_text_field_custom).assertDoesNotExist() - searchInput.printToLog("searchInput") - // Verify the text is cleared - searchInput.assert( - SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("")) - ) + // Input text into the search field + val searchInput = composeTestRule.onNodeWithTag("searchContent") + searchInput.performTextInput("electrician") + searchInput.assertTextEquals("electrician") // Verify text input + + // Click the trailing icon (clear button) + composeTestRule.onNodeWithTag(C.Tag.clear_button_text_field_custom).performClick() + + // Wait for UI to settle + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(C.Tag.clear_button_text_field_custom).assertDoesNotExist() + searchInput.printToLog("searchInput") + // Verify the text is cleared + searchInput.assert( + SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(""))) + } + + @Test + fun searchOnBoarding_switchesFromCategoriesToProfiles() { + composeTestRule.setContent { + SearchOnBoarding( + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) } - @Test - fun searchOnBoarding_switchesFromCategoriesToProfiles() { - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Verify initial state (Categories are displayed) - composeTestRule.onNodeWithText("Categories").assertIsDisplayed() - composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") - - // Verify state after query input (Categories disappear, Profiles appear) - composeTestRule.onNodeWithText("Categories").assertDoesNotExist() + // Verify initial state (Categories are displayed) + composeTestRule.onNodeWithText("Categories").assertIsDisplayed() + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + + // Verify state after query input (Categories disappear, Profiles appear) + composeTestRule.onNodeWithText("Categories").assertDoesNotExist() + } + + @Test + fun searchOnBoarding_showsFilterButtonsWhenQueryIsNotEmpty() { + composeTestRule.setContent { + SearchOnBoarding( + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) } - @Test - fun searchOnBoarding_showsFilterButtonsWhenQueryIsNotEmpty() { - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Input text to simulate non-empty search - composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") - - composeTestRule.onNodeWithTag("tuneButton").performClick() - - // Verify that the filter row becomes visible - composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() + // Input text to simulate non-empty search + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + + composeTestRule.onNodeWithTag("tuneButton").performClick() + + // Verify that the filter row becomes visible + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() + } + + @Test + fun testOpenAvailabilityFilterMenu() { + // Set up test worker profiles + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + unavailability_list = listOf(LocalDate.now()), + location = Location(40.7128, -74.0060)) + + val worker2 = + WorkerProfile( + uid = "worker2", + fieldOfWork = "Electrician", + rating = 4.0, + workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + // Update the searchViewModel with test workers + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2) + + // Set the composable content + composeTestRule.setContent { + SearchOnBoarding( + navigationActions = navigationActions, + navigationActionsRoot = navigationActionsRoot, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel, + onProfileClick = { _ -> }, + ) } - @Test - fun testOpenAvailabilityFilterMenu() { - // Set up test worker profiles - val worker1 = - WorkerProfile( - uid = "worker1", - fieldOfWork = "Painter", - rating = 4.5, - workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), - unavailability_list = listOf(LocalDate.now()), - location = Location(40.7128, -74.0060) - ) - - val worker2 = - WorkerProfile( - uid = "worker2", - fieldOfWork = "Electrician", - rating = 4.0, - workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - // Update the searchViewModel with test workers - searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2) - - // Set the composable content - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions = navigationActions, - navigationActionsRoot = navigationActionsRoot, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - categoryViewModel = categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Wait for the UI to settle - composeTestRule.waitForIdle() - - // Perform a search query to display filter buttons - composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() - .performScrollToIndex(3) - - // Scroll to the "Availability" filter button - - // Click the "Availability" filter button - composeTestRule.onNodeWithText("Availability").performClick() - - // Wait for the availability bottom sheet to appear - composeTestRule.waitForIdle() - - // Verify the bottom sheet and its components are displayed - composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() - composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() - composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() - composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() - composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() - - val today = LocalDate.now() - val day = today.dayOfMonth - - val textFields = - composeTestRule.onAllNodes(hasSetTextAction()) - .filter(hasParent(hasTestTag("timeInput"))) - - // Ensure that we have at least two text fields - assert(textFields.fetchSemanticsNodes().size >= 2) - - // Set the hour to "07" - textFields[0].performTextReplacement("08") - - // Set the minute to "00" - textFields[1].performTextReplacement("00") - - // Find the node representing today's date and perform a click - composeTestRule - .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) - .performClick() - - composeTestRule.onNodeWithText("OK").performClick() - - composeTestRule.waitForIdle() - - // Verify that no workers are displayed - } + // Wait for the UI to settle + composeTestRule.waitForIdle() + + // Perform a search query to display filter buttons + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed().performScrollToIndex(3) + + // Scroll to the "Availability" filter button + + // Click the "Availability" filter button + composeTestRule.onNodeWithText("Availability").performClick() + + // Wait for the availability bottom sheet to appear + composeTestRule.waitForIdle() + + // Verify the bottom sheet and its components are displayed + composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() + composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() + composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() + composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() + + val today = LocalDate.now() + val day = today.dayOfMonth + + val textFields = + composeTestRule.onAllNodes(hasSetTextAction()).filter(hasParent(hasTestTag("timeInput"))) - @Test - fun testOpenPriceRangeFilterMenu() { - // Set up test worker profiles - val worker1 = - WorkerProfile( - uid = "worker1", - fieldOfWork = "Painter", - rating = 4.5, - workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), - location = Location(40.7128, -74.0060) - ) - - searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1) - - // Set the composable content - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions = navigationActions, - navigationActionsRoot = navigationActionsRoot, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - categoryViewModel = categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Perform a search query to display filter buttons - composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() - .performScrollToIndex(5) - - // Click on "Price Range" filter button - composeTestRule.onNodeWithText("Price Range").performClick() - - // Verify the bottom sheet appears - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("priceRangeModalSheet").assertIsDisplayed() + // Ensure that we have at least two text fields + assert(textFields.fetchSemanticsNodes().size >= 2) + + // Set the hour to "07" + textFields[0].performTextReplacement("08") + + // Set the minute to "00" + textFields[1].performTextReplacement("00") + + // Find the node representing today's date and perform a click + composeTestRule + .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) + .performClick() + + composeTestRule.onNodeWithText("OK").performClick() + + composeTestRule.waitForIdle() + + // Verify that no workers are displayed + } + + @Test + fun testOpenPriceRangeFilterMenu() { + // Set up test worker profiles + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + location = Location(40.7128, -74.0060)) + + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1) + + // Set the composable content + composeTestRule.setContent { + SearchOnBoarding( + navigationActions = navigationActions, + navigationActionsRoot = navigationActionsRoot, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel, + onProfileClick = { _ -> }, + ) } - @Test - fun testOpenLocationFilterMenu() { - // Set up test worker profiles - val worker1 = - WorkerProfile( - uid = "worker1", - fieldOfWork = "Painter", - rating = 4.5, - workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), - location = Location(40.7128, -74.0060) - ) - - searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1) - - // Set the composable content - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions = navigationActions, - navigationActionsRoot = navigationActionsRoot, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - categoryViewModel = categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Perform a search query to display filter buttons - composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() - - // Click on "Location" filter button - composeTestRule.onNodeWithText("Location").performClick() - - // Verify the bottom sheet appears - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() + // Perform a search query to display filter buttons + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed().performScrollToIndex(5) + + // Click on "Price Range" filter button + composeTestRule.onNodeWithText("Price Range").performClick() + + // Verify the bottom sheet appears + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("priceRangeModalSheet").assertIsDisplayed() + } + + @Test + fun testOpenLocationFilterMenu() { + // Set up test worker profiles + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + location = Location(40.7128, -74.0060)) + + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1) + + // Set the composable content + composeTestRule.setContent { + SearchOnBoarding( + navigationActions = navigationActions, + navigationActionsRoot = navigationActionsRoot, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel, + onProfileClick = { _ -> }, + ) } - @Test - fun searchOnBoarding_cancelButtonNavigatesHome() { - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Click the Cancel button - composeTestRule.onNodeWithText("Cancel").performClick() - - // Verify navigation to HOME was requested - verify(navigationActionsRoot).navigateTo(UserTopLevelDestinations.HOME) + // Perform a search query to display filter buttons + composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() + + // Click on "Location" filter button + composeTestRule.onNodeWithText("Location").performClick() + + // Verify the bottom sheet appears + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() + } + + @Test + fun searchOnBoarding_cancelButtonNavigatesHome() { + composeTestRule.setContent { + SearchOnBoarding( + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) } - @Test - fun searchOnBoarding_servicesFilterApplyAndClear() { - // Set up some mock services in searchViewModel - searchViewModel._searchSubcategory.value = - Subcategory(name = "TestSubCategory", tags = listOf("Service1", "Service2")) - - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Perform a search to show filters - composeTestRule.onNodeWithTag("searchContent").performTextInput("Test") - composeTestRule.onNodeWithTag("tuneButton").performClick() - - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - // Open services bottom sheet - composeTestRule.onNodeWithText("Service Type").performClick() - - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertIsDisplayed() - - // Simulate selecting "Interior Painter" - composeTestRule.onNodeWithText("Apply").performClick() + // Click the Cancel button + composeTestRule.onNodeWithText("Cancel").performClick() + + // Verify navigation to HOME was requested + verify(navigationActionsRoot).navigateTo(UserTopLevelDestinations.HOME) + } + + @Test + fun searchOnBoarding_servicesFilterApplyAndClear() { + // Set up some mock services in searchViewModel + searchViewModel._searchSubcategory.value = + Subcategory(name = "TestSubCategory", tags = listOf("Service1", "Service2")) + + composeTestRule.setContent { + SearchOnBoarding( + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) } - @Test - fun searchOnBoarding_priceRangeFilterApplyAndClear() { - composeTestRule.setContent { - SearchOnBoarding( - onSearch = {}, - onSearchEmpty = {}, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel, - onProfileClick = { _ -> }, - quickFixViewModel - ) - } - - // Perform a search to show filters - composeTestRule.onNodeWithTag("searchContent").performTextInput("Tester") - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) - composeTestRule.onNodeWithText("Price Range").performClick() - - // Input a price range and apply - composeTestRule.onNodeWithText("Apply").performClick() - - // Open price range again and clear - composeTestRule.onNodeWithText("Price Range").performClick() - composeTestRule.onNodeWithText("Clear").performClick() + // Perform a search to show filters + composeTestRule.onNodeWithTag("searchContent").performTextInput("Test") + composeTestRule.onNodeWithTag("tuneButton").performClick() + + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + // Open services bottom sheet + composeTestRule.onNodeWithText("Service Type").performClick() + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertIsDisplayed() + + // Simulate selecting "Interior Painter" + composeTestRule.onNodeWithText("Apply").performClick() + } + + @Test + fun searchOnBoarding_priceRangeFilterApplyAndClear() { + composeTestRule.setContent { + SearchOnBoarding( + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { _ -> }, + ) } + + // Perform a search to show filters + composeTestRule.onNodeWithTag("searchContent").performTextInput("Tester") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) + composeTestRule.onNodeWithText("Price Range").performClick() + + // Input a price range and apply + composeTestRule.onNodeWithText("Apply").performClick() + + // Open price range again and clear + composeTestRule.onNodeWithText("Price Range").performClick() + composeTestRule.onNodeWithText("Clear").performClick() + } } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt index 6fdb5a5a..10f190ec 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt @@ -56,6 +56,9 @@ import com.arygm.quickfix.utils.LocationHelper import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.tasks.OnCompleteListener import com.google.android.gms.tasks.Task +import java.time.LocalDate +import java.time.LocalTime +import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule @@ -71,2189 +74,1993 @@ import org.mockito.MockitoAnnotations import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.whenever -import java.time.LocalDate -import java.time.LocalTime -import kotlin.math.roundToInt @RunWith(AndroidJUnit4::class) class SearchWorkerResultScreenTest { - private lateinit var navigationActions: NavigationActions - private lateinit var searchViewModel: SearchViewModel - private lateinit var workerRepository: WorkerProfileRepositoryFirestore - private lateinit var categoryRepository: CategoryRepositoryFirestore - private lateinit var accountViewModel: AccountViewModel - private lateinit var accountRepository: AccountRepositoryFirestore - private lateinit var userProfileRepositoryFirestore: ProfileRepository - private lateinit var userViewModel: ProfileViewModel - private lateinit var preferencesViewModel: PreferencesViewModel - private lateinit var preferencesRepositoryDataStore: PreferencesRepository - private lateinit var quickFixRepositoryFirestore: QuickFixRepositoryFirestore - private lateinit var quickFixViewModel: QuickFixViewModel - private lateinit var context: Context - private lateinit var activity: Activity - private lateinit var locationHelper: LocationHelper - private lateinit var fusedLocationProviderClient: FusedLocationProviderClient - - @get:Rule - val composeTestRule = createComposeRule() - - @Before - fun setup() { - // Initialize Mockito - MockitoAnnotations.openMocks(this) - - // Mock dependencies - navigationActions = mock(NavigationActions::class.java) - workerRepository = mock(WorkerProfileRepositoryFirestore::class.java) - categoryRepository = mock(CategoryRepositoryFirestore::class.java) - accountRepository = mock(AccountRepositoryFirestore::class.java) - quickFixRepositoryFirestore = mock(QuickFixRepositoryFirestore::class.java) - userProfileRepositoryFirestore = mock(ProfileRepository::class.java) - preferencesRepositoryDataStore = mock(PreferencesRepository::class.java) - - // Mock the flow returned by the repository - val mockedPreferenceFlow = MutableStateFlow(null) - whenever(preferencesRepositoryDataStore.getPreferenceByKey(any>())) - .thenReturn(mockedPreferenceFlow) - - // Initialize PreferencesViewModel with mocked repository - preferencesViewModel = PreferencesViewModel(preferencesRepositoryDataStore) - - // Initialize other ViewModels with mocked repositories - searchViewModel = SearchViewModel(workerRepository) - accountViewModel = AccountViewModel(accountRepository) - quickFixViewModel = QuickFixViewModel(quickFixRepositoryFirestore) - userViewModel = ProfileViewModel(userProfileRepositoryFirestore) - - // Provide test data to SearchViewModel - searchViewModel._subCategoryWorkerProfiles.value = - listOf( - WorkerProfile( - uid = "test_uid_1", - price = 1.0, - fieldOfWork = "Carpentry", - rating = 3.0, - description = "I hate my job", - location = Location(40.7128, -74.0060) - ) - ) - - // Mock the getAccountById method to always return a test Account - doAnswer { invocation -> - val uid = invocation.arguments[0] as String - val onSuccess = invocation.arguments[1] as (Account?) -> Unit - val onFailure = invocation.arguments[2] as (Exception) -> Unit - - // Create a test Account object - val testAccount = - Account( - uid = uid, - firstName = "TestFirstName", - lastName = "TestLastName", - email = "test@example.com", - birthDate = com.google.firebase.Timestamp.now(), - isWorker = true, - activeChats = emptyList() - ) - onSuccess(testAccount) - null + private lateinit var navigationActions: NavigationActions + private lateinit var searchViewModel: SearchViewModel + private lateinit var workerRepository: WorkerProfileRepositoryFirestore + private lateinit var categoryRepository: CategoryRepositoryFirestore + private lateinit var accountViewModel: AccountViewModel + private lateinit var accountRepository: AccountRepositoryFirestore + private lateinit var userProfileRepositoryFirestore: ProfileRepository + private lateinit var userViewModel: ProfileViewModel + private lateinit var preferencesViewModel: PreferencesViewModel + private lateinit var preferencesRepositoryDataStore: PreferencesRepository + private lateinit var quickFixRepositoryFirestore: QuickFixRepositoryFirestore + private lateinit var quickFixViewModel: QuickFixViewModel + private lateinit var context: Context + private lateinit var activity: Activity + private lateinit var locationHelper: LocationHelper + private lateinit var fusedLocationProviderClient: FusedLocationProviderClient + + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun setup() { + // Initialize Mockito + MockitoAnnotations.openMocks(this) + + // Mock dependencies + navigationActions = mock(NavigationActions::class.java) + workerRepository = mock(WorkerProfileRepositoryFirestore::class.java) + categoryRepository = mock(CategoryRepositoryFirestore::class.java) + accountRepository = mock(AccountRepositoryFirestore::class.java) + quickFixRepositoryFirestore = mock(QuickFixRepositoryFirestore::class.java) + userProfileRepositoryFirestore = mock(ProfileRepository::class.java) + preferencesRepositoryDataStore = mock(PreferencesRepository::class.java) + + // Mock the flow returned by the repository + val mockedPreferenceFlow = MutableStateFlow(null) + whenever(preferencesRepositoryDataStore.getPreferenceByKey(any>())) + .thenReturn(mockedPreferenceFlow) + + // Initialize PreferencesViewModel with mocked repository + preferencesViewModel = PreferencesViewModel(preferencesRepositoryDataStore) + + // Initialize other ViewModels with mocked repositories + searchViewModel = SearchViewModel(workerRepository) + accountViewModel = AccountViewModel(accountRepository) + quickFixViewModel = QuickFixViewModel(quickFixRepositoryFirestore) + userViewModel = ProfileViewModel(userProfileRepositoryFirestore) + + // Provide test data to SearchViewModel + searchViewModel._subCategoryWorkerProfiles.value = + listOf( + WorkerProfile( + uid = "test_uid_1", + price = 1.0, + fieldOfWork = "Carpentry", + rating = 3.0, + description = "I hate my job", + location = Location(40.7128, -74.0060))) + + // Mock the getAccountById method to always return a test Account + doAnswer { invocation -> + val uid = invocation.arguments[0] as String + val onSuccess = invocation.arguments[1] as (Account?) -> Unit + val onFailure = invocation.arguments[2] as (Exception) -> Unit + + // Create a test Account object + val testAccount = + Account( + uid = uid, + firstName = "TestFirstName", + lastName = "TestLastName", + email = "test@example.com", + birthDate = com.google.firebase.Timestamp.now(), + isWorker = true, + activeChats = emptyList()) + onSuccess(testAccount) + null } - .`when`(accountRepository) - .getAccountById(anyString(), any(), any()) - - // Mock fetchUserProfile so that it returns a UserProfile with a "Home" location - doAnswer { invocation -> - val uid = invocation.arguments[0] as String - val onSuccess = invocation.arguments[1] as (Profile?) -> Unit - val onFailure = invocation.arguments[2] as (Exception) -> Unit - - // Return a user profile with a "Home" location - val testUserProfile = - UserProfile( - locations = listOf(Location(latitude = 40.0, longitude = -74.0, name = "Home")), - announcements = emptyList(), - uid = uid - ) - onSuccess(testUserProfile) - null + .`when`(accountRepository) + .getAccountById(anyString(), any(), any()) + + // Mock fetchUserProfile so that it returns a UserProfile with a "Home" location + doAnswer { invocation -> + val uid = invocation.arguments[0] as String + val onSuccess = invocation.arguments[1] as (Profile?) -> Unit + val onFailure = invocation.arguments[2] as (Exception) -> Unit + + // Return a user profile with a "Home" location + val testUserProfile = + UserProfile( + locations = listOf(Location(latitude = 40.0, longitude = -74.0, name = "Home")), + announcements = emptyList(), + uid = uid) + onSuccess(testUserProfile) + null } - .`when`(userProfileRepositoryFirestore) - .getProfileById(anyString(), any(), any()) + .`when`(userProfileRepositoryFirestore) + .getProfileById(anyString(), any(), any()) + } + + @Test + fun testTopAppBarIsDisplayed() { + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - - @Test - fun testTopAppBarIsDisplayed() { - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - // Verify that Back and Search icons are present in the top bar - composeTestRule.onNodeWithContentDescription("Back").assertExists().assertIsDisplayed() - composeTestRule.onNodeWithContentDescription("Search").assertExists().assertIsDisplayed() + // Verify that Back and Search icons are present in the top bar + composeTestRule.onNodeWithContentDescription("Back").assertExists().assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Search").assertExists().assertIsDisplayed() + } + + @Test + fun testTitleAndDescriptionAreDisplayed() { + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - - @Test - fun testTitleAndDescriptionAreDisplayed() { - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - // Set the search query and verify that the title and description match the query - searchViewModel.setSearchQuery("Unknown") - - // Check if the description with the query text is displayed - composeTestRule.onAllNodesWithText("Unknown").assertCountEquals(2) + // Set the search query and verify that the title and description match the query + searchViewModel.setSearchQuery("Unknown") + + // Check if the description with the query text is displayed + composeTestRule.onAllNodesWithText("Unknown").assertCountEquals(2) + } + + @Test + fun testFilterButtonsAreDisplayed() { + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testFilterButtonsAreDisplayed() { - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - - // Wait for the UI to settle - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithTag("tuneButton").performClick() - // Verify that the LazyRow for filter buttons is visible - val filterButtonsRow = composeTestRule.onNodeWithTag("filter_buttons_row") - filterButtonsRow.assertExists().assertIsDisplayed() - - // Define the expected button texts - val expectedButtons = - listOf("Location", "Service Type", "Availability", "Highest Rating", "Price Range") - - // Verify each button exists, is displayed, and clickable - expectedButtons.forEachIndexed { index, buttonText -> - // Scroll to the button index (important for last two buttons in LazyRow) - filterButtonsRow.performScrollToIndex(index) - - // Assert button properties - composeTestRule - .onNodeWithText(buttonText) - .assertExists() - .assertIsDisplayed() - .assertHasClickAction() - } + // Wait for the UI to settle + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("tuneButton").performClick() + // Verify that the LazyRow for filter buttons is visible + val filterButtonsRow = composeTestRule.onNodeWithTag("filter_buttons_row") + filterButtonsRow.assertExists().assertIsDisplayed() + + // Define the expected button texts + val expectedButtons = + listOf("Location", "Service Type", "Availability", "Highest Rating", "Price Range") + + // Verify each button exists, is displayed, and clickable + expectedButtons.forEachIndexed { index, buttonText -> + // Scroll to the button index (important for last two buttons in LazyRow) + filterButtonsRow.performScrollToIndex(index) + + // Assert button properties + composeTestRule + .onNodeWithText(buttonText) + .assertExists() + .assertIsDisplayed() + .assertHasClickAction() + } + } + + @Test + fun testFilterIconButtonIsDisplayedAndClickable() { + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } + // Verify that the filter icon button is displayed and has a click action + composeTestRule + .onNodeWithContentDescription("Filter") + .assertExists() + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun testProfileResultsAreDisplayed() { + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } + // Scroll through the LazyColumn and verify each profile result is displayed + val workerProfilesList = composeTestRule.onNodeWithTag("worker_profiles_list") + + repeat(searchViewModel.workerProfiles.value.size) { index -> + workerProfilesList.performScrollToIndex(index) + composeTestRule + .onNodeWithTag("worker_profile_result$index") + .assertExists() + .assertIsDisplayed() + } + } + + @Test + fun testNavigationBackActionIsInvokedOnBackButtonClick() { + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } + // Perform click on the back button and verify goBack() is called + composeTestRule.onNodeWithContentDescription("Back").performClick() + verify(navigationActions).goBack() + } + + @Test + fun testSlidingWindowAppearsOnBookClick() { + // Set up the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testFilterIconButtonIsDisplayedAndClickable() { - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - // Verify that the filter icon button is displayed and has a click action - composeTestRule - .onNodeWithContentDescription("Filter") - .assertExists() - .assertIsDisplayed() - .assertHasClickAction() + // Wait for the UI to settle + composeTestRule.waitForIdle() + + // Scroll to ensure the item is composed + composeTestRule.onNodeWithTag("worker_profiles_list").performScrollToIndex(0) + + // Click on the "Book" button + composeTestRule.onNodeWithTag("book_button").assertExists().performClick() + + // Wait for the sliding window to appear + composeTestRule.waitForIdle() + + // Check that the sliding window content is displayed + composeTestRule.onNodeWithTag("sliding_window_content").assertExists().assertIsDisplayed() + } + + @Test + fun testBannerImageIsDisplayed() { + // Set up the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testProfileResultsAreDisplayed() { - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - // Scroll through the LazyColumn and verify each profile result is displayed - val workerProfilesList = composeTestRule.onNodeWithTag("worker_profiles_list") - - repeat(searchViewModel.workerProfiles.value.size) { index -> - workerProfilesList.performScrollToIndex(index) - composeTestRule - .onNodeWithTag("worker_profile_result$index") - .assertExists() - .assertIsDisplayed() - } + // Wait until the worker profiles are displayed + composeTestRule.waitForIdle() + + // Click on the "Book" button of the first item + composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + + // Wait for the sliding window to appear + composeTestRule.waitForIdle() + + // Verify the banner image is displayed + composeTestRule.onNodeWithTag("sliding_window_banner_image").assertExists().assertIsDisplayed() + } + + @Test + fun testProfilePictureIsDisplayed() { + // Set up the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testNavigationBackActionIsInvokedOnBackButtonClick() { - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - // Perform click on the back button and verify goBack() is called - composeTestRule.onNodeWithContentDescription("Back").performClick() - verify(navigationActions).goBack() + // Wait until the worker profiles are displayed + composeTestRule.waitForIdle() + + // Click on the "Book" button of the first item + composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + + // Wait for the sliding window to appear + composeTestRule.waitForIdle() + + // Verify the profile picture is displayed + composeTestRule + .onNodeWithTag("sliding_window_profile_picture") + .assertExists() + .assertIsDisplayed() + } + + @Test + fun testWorkerCategoryAndAddressAreDisplayed() { + // Set up the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testSlidingWindowAppearsOnBookClick() { - // Set up the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Wait until the worker profiles are displayed + composeTestRule.waitForIdle() + + // Click on the "Book" button of the first item + composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + + // Wait for the sliding window to appear + composeTestRule.waitForIdle() + + // Verify the worker category is displayed + composeTestRule + .onNodeWithTag("sliding_window_worker_category") + .assertExists() + .assertIsDisplayed() + .assertTextContains("Exterior Painter") // Replace with expected category + + // Verify the worker address is displayed + composeTestRule + .onNodeWithTag("sliding_window_worker_address") + .assertExists() + .assertIsDisplayed() + .assertTextContains("Ecublens, VD") // Replace with expected address + } + + @Test + fun testIncludedServicesAreDisplayed() { + // Set up the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Wait for the UI to settle - composeTestRule.waitForIdle() + // Wait until the worker profiles are displayed + composeTestRule.waitForIdle() - // Scroll to ensure the item is composed - composeTestRule.onNodeWithTag("worker_profiles_list").performScrollToIndex(0) + // Click on the "Book" button of the first item + composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() - // Click on the "Book" button - composeTestRule.onNodeWithTag("book_button").assertExists().performClick() + // Wait for the sliding window to appear + composeTestRule.waitForIdle() - // Wait for the sliding window to appear - composeTestRule.waitForIdle() + // Verify the included services section is displayed + composeTestRule + .onNodeWithTag("sliding_window_included_services_column") + .assertExists() + .assertIsDisplayed() + // Check for each included service + val includedServices = listOf("Painting") - // Check that the sliding window content is displayed - composeTestRule.onNodeWithTag("sliding_window_content").assertExists().assertIsDisplayed() + includedServices.forEach { service -> + composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() + } + } + + @Test + fun testAddOnServicesAreDisplayed() { + // Set up the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testBannerImageIsDisplayed() { - // Set up the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Wait until the worker profiles are displayed + composeTestRule.waitForIdle() + + // Click on the "Book" button of the first item + composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() - // Wait until the worker profiles are displayed - composeTestRule.waitForIdle() + // Wait for the sliding window to appear + composeTestRule.waitForIdle() - // Click on the "Book" button of the first item - composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + // Verify the add-on services section is displayed + composeTestRule + .onNodeWithTag("sliding_window_addon_services_column") + .assertExists() + .assertIsDisplayed() - // Wait for the sliding window to appear - composeTestRule.waitForIdle() + // Check for each add-on service + val addOnServices = listOf("Window Cleaning", "Furniture Assembly") - // Verify the banner image is displayed - composeTestRule.onNodeWithTag("sliding_window_banner_image").assertExists() - .assertIsDisplayed() + addOnServices.forEach { service -> + composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() + } + } + + @Test + fun testContinueButtonIsDisplayedAndClickable() { + // Set up the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testProfilePictureIsDisplayed() { - // Set up the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Wait until the worker profiles are displayed + composeTestRule.waitForIdle() + + // Click on the "Book" button of the first item + composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + + // Wait for the sliding window to appear + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("starsRow").assertExists().assertIsDisplayed() + + composeTestRule.onNodeWithTag("Star_0").assertExists().assertIsDisplayed() + composeTestRule.onNodeWithTag("Star_1").assertExists().assertIsDisplayed() + composeTestRule.onNodeWithTag("Star_2").assertExists().assertIsDisplayed() + composeTestRule.onNodeWithTag("Star_3").assertExists().assertIsDisplayed() + composeTestRule.onNodeWithTag("Star_4").assertExists().assertIsDisplayed() + + // Verify the "Continue" button is displayed and clickable + composeTestRule + .onNodeWithTag("sliding_window_continue_button") + .assertExists() + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun testTagsAreDisplayed() { + // Set up the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Wait until the worker profiles are displayed - composeTestRule.waitForIdle() + // Wait until the worker profiles are displayed + composeTestRule.waitForIdle() - // Click on the "Book" button of the first item - composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + // Click on the "Book" button of the first item + composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() - // Wait for the sliding window to appear - composeTestRule.waitForIdle() + // Wait for the sliding window to appear + composeTestRule.waitForIdle() - // Verify the profile picture is displayed - composeTestRule - .onNodeWithTag("sliding_window_profile_picture") - .assertExists() - .assertIsDisplayed() - } + // Verify the tags section is displayed + composeTestRule.onNodeWithTag("sliding_window_tags_flow_row").assertExists().assertIsDisplayed() - @Test - fun testWorkerCategoryAndAddressAreDisplayed() { - // Set up the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Check for each tag + val tags = listOf("Painter", "Gardener") - // Wait until the worker profiles are displayed - composeTestRule.waitForIdle() + tags.forEach { tag -> composeTestRule.onNodeWithText(tag).assertExists().assertIsDisplayed() } + } - // Click on the "Book" button of the first item - composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + @Test + fun testSaveButtonTogglesBetweenSaveAndSaved() { + // Set up the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Wait for the sliding window to appear - composeTestRule.waitForIdle() + // Wait until the worker profiles are displayed + composeTestRule.waitForIdle() + + // Click on the "Book" button of the first item + composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + + // Wait for the sliding window to appear + composeTestRule.waitForIdle() + + // Verify the "save" button is displayed + composeTestRule + .onNodeWithTag("sliding_window_save_button") + .assertExists() + .assertIsDisplayed() + .assertTextContains("save") + + // Click on the "save" button + composeTestRule.onNodeWithTag("sliding_window_save_button").performClick() + + // Wait for the UI to update + composeTestRule.waitForIdle() + + // Verify the button text changes to "saved" + composeTestRule + .onNodeWithTag("sliding_window_save_button") + .assertExists() + .assertIsDisplayed() + .assertTextContains("saved") + + // Click again to toggle back to "save" + composeTestRule.onNodeWithTag("sliding_window_save_button").performClick() + + // Wait for the UI to update + composeTestRule.waitForIdle() + + // Verify the button text changes back to "save" + composeTestRule + .onNodeWithTag("sliding_window_save_button") + .assertExists() + .assertIsDisplayed() + .assertTextContains("save") + } + + @Test + fun testWorkerFilteringByAvailabilityDays() { + // Set up test worker profiles with specific working hours and unavailability + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + unavailability_list = listOf(LocalDate.now()), + location = Location(40.7128, -74.0060)) + + val worker2 = + WorkerProfile( + uid = "worker2", + fieldOfWork = "Electrician", + rating = 4.0, + workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + val worker3 = + WorkerProfile( + uid = "worker3", + fieldOfWork = "Plumber", + rating = 5.0, + workingHours = Pair(LocalTime.of(10, 0), LocalTime.of(18, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + // Update the searchViewModel with these test workers + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2, worker3) + + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Verify the worker category is displayed - composeTestRule - .onNodeWithTag("sliding_window_worker_category") - .assertExists() - .assertIsDisplayed() - .assertTextContains("Exterior Painter") // Replace with expected category + // Initially, all workers should be displayed + composeTestRule.waitForIdle() + + // Verify that all 3 workers are displayed + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) + + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) + // Simulate clicking the "Availability" filter button + composeTestRule.onNodeWithText("Availability").performClick() + + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() + composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() + composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() + composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() + + val today = LocalDate.now() + val day = today.dayOfMonth + + val textFields = + composeTestRule.onAllNodes(hasSetTextAction()).filter(hasParent(hasTestTag("timeInput"))) + + // Ensure that we have at least two text fields + assert(textFields.fetchSemanticsNodes().size >= 2) + + // Set the hour to "07" + textFields[0].performTextReplacement("10") + + // Set the minute to "00" + textFields[1].performTextReplacement("00") + + // Find the node representing today's date and perform a click + composeTestRule + .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) + .performClick() + + composeTestRule.onNodeWithText("OK").performClick() + + composeTestRule.waitForIdle() + + // Verify that 2 workers are displayed + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) + } + + @Test + fun testWorkerFilteringByAvailabilityHours() { + // Set up test worker profiles with specific working hours and unavailability + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + val worker2 = + WorkerProfile( + uid = "worker2", + fieldOfWork = "Electrician", + rating = 4.0, + workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + val worker3 = + WorkerProfile( + uid = "worker3", + fieldOfWork = "Plumber", + rating = 5.0, + workingHours = Pair(LocalTime.of(10, 0), LocalTime.of(18, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + // Update the searchViewModel with these test workers + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2, worker3) + + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Verify the worker address is displayed - composeTestRule - .onNodeWithTag("sliding_window_worker_address") - .assertExists() - .assertIsDisplayed() - .assertTextContains("Ecublens, VD") // Replace with expected address + // Initially, all workers should be displayed + composeTestRule.waitForIdle() + + // Verify that all 3 workers are displayed + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) + + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) + // Simulate clicking the "Availability" filter button + composeTestRule.onNodeWithText("Availability").performClick() + + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() + composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() + composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() + composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() + + val today = LocalDate.now() + val day = today.dayOfMonth + + val textFields = + composeTestRule.onAllNodes(hasSetTextAction()).filter(hasParent(hasTestTag("timeInput"))) + + // Ensure that we have at least two text fields + assert(textFields.fetchSemanticsNodes().size >= 2) + + // Set the hour to "07" + textFields[0].performTextReplacement("08") + + // Set the minute to "00" + textFields[1].performTextReplacement("00") + + // Find the node representing today's date and perform a click + composeTestRule + .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) + .performClick() + + composeTestRule.onNodeWithText("OK").performClick() + + composeTestRule.waitForIdle() + + // Verify that one worker is displayed + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) + } + + @Test + fun testWorkerFilteringByAvailabilityMinutes() { + // Set up test worker profiles with specific working hours and unavailability + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + val worker2 = + WorkerProfile( + uid = "worker2", + fieldOfWork = "Electrician", + rating = 4.0, + workingHours = Pair(LocalTime.of(8, 30), LocalTime.of(16, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + val worker3 = + WorkerProfile( + uid = "worker3", + fieldOfWork = "Plumber", + rating = 5.0, + workingHours = Pair(LocalTime.of(10, 0), LocalTime.of(18, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + // Update the searchViewModel with these test workers + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2, worker3) + + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testIncludedServicesAreDisplayed() { - // Set up the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Initially, all workers should be displayed + composeTestRule.waitForIdle() - // Wait until the worker profiles are displayed - composeTestRule.waitForIdle() + // Verify that all 3 workers are displayed + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - // Click on the "Book" button of the first item - composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) + // Simulate clicking the "Availability" filter button + composeTestRule.onNodeWithText("Availability").performClick() - // Wait for the sliding window to appear - composeTestRule.waitForIdle() + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() - // Verify the included services section is displayed - composeTestRule - .onNodeWithTag("sliding_window_included_services_column") - .assertExists() - .assertIsDisplayed() - // Check for each included service - val includedServices = listOf("Painting") + composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() + composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() + composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() + composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() - includedServices.forEach { service -> - composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() - } - } + val today = LocalDate.now() + val day = today.dayOfMonth - @Test - fun testAddOnServicesAreDisplayed() { - // Set up the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + val textFields = + composeTestRule.onAllNodes(hasSetTextAction()).filter(hasParent(hasTestTag("timeInput"))) - // Wait until the worker profiles are displayed - composeTestRule.waitForIdle() + // Ensure that we have at least two text fields + assert(textFields.fetchSemanticsNodes().size >= 2) - // Click on the "Book" button of the first item - composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + // Set the hour to "07" + textFields[0].performTextReplacement("08") - // Wait for the sliding window to appear - composeTestRule.waitForIdle() + // Set the minute to "00" + textFields[1].performTextReplacement("00") - // Verify the add-on services section is displayed - composeTestRule - .onNodeWithTag("sliding_window_addon_services_column") - .assertExists() - .assertIsDisplayed() + // Find the node representing today's date and perform a click + composeTestRule + .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) + .performClick() - // Check for each add-on service - val addOnServices = listOf("Window Cleaning", "Furniture Assembly") + composeTestRule.onNodeWithText("OK").performClick() - addOnServices.forEach { service -> - composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() - } + composeTestRule.waitForIdle() + + // Verify that no workers are displayed + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(0) + } + + @Test + fun testWorkerFilteringByServices() { + val workers = + listOf( + WorkerProfile( + uid = "worker1", + tags = listOf("Exterior Painter", "Interior Painter"), + rating = 4.5, + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker2", + tags = listOf("Interior Painter", "Electrician"), + rating = 4.0, + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker3", + tags = listOf("Plumber"), + rating = 5.0, + location = Location(40.7128, -74.0060))) + + // Set up subcategory tags + searchViewModel._searchSubcategory.value = + Subcategory(tags = listOf("Exterior Painter", "Interior Painter", "Electrician", "Plumber")) + + searchViewModel._subCategoryWorkerProfiles.value = workers + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testContinueButtonIsDisplayedAndClickable() { - // Set up the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + // Click on the "Service Type" filter button + composeTestRule.onNodeWithText("Service Type").performClick() - // Wait until the worker profiles are displayed - composeTestRule.waitForIdle() + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() - // Click on the "Book" button of the first item - composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertIsDisplayed() - // Wait for the sliding window to appear - composeTestRule.waitForIdle() + // Simulate selecting "Interior Painter" + composeTestRule.onNodeWithText("Interior Painter").performClick() + composeTestRule.onNodeWithText("Apply").performClick() - composeTestRule.onNodeWithTag("starsRow").assertExists().assertIsDisplayed() + composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("Star_0").assertExists().assertIsDisplayed() - composeTestRule.onNodeWithTag("Star_1").assertExists().assertIsDisplayed() - composeTestRule.onNodeWithTag("Star_2").assertExists().assertIsDisplayed() - composeTestRule.onNodeWithTag("Star_3").assertExists().assertIsDisplayed() - composeTestRule.onNodeWithTag("Star_4").assertExists().assertIsDisplayed() + // Verify filtered workers + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) + } - // Verify the "Continue" button is displayed and clickable - composeTestRule - .onNodeWithTag("sliding_window_continue_button") - .assertExists() - .assertIsDisplayed() - .assertHasClickAction() + @Test + fun testWorkerSortingByRating() { + val workers = + listOf( + WorkerProfile( + uid = "worker1", + displayName = "Worker One", + tags = listOf("Electrician"), + reviews = + ArrayDeque( + listOf( + Review(username = "User1", review = "Great service!", rating = 3.5))), + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker2", + displayName = "Worker Two", + tags = listOf("Electrician"), + reviews = + ArrayDeque( + listOf( + Review(username = "User1", review = "Great service!", rating = 4.8))), + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker3", + displayName = "Worker Three", + tags = listOf("Electrician"), + reviews = + ArrayDeque( + listOf( + Review(username = "User1", review = "Great service!", rating = 2.9))), + location = Location(40.7128, -74.0060))) + + // Provide test data to the searchViewModel + searchViewModel._subCategoryWorkerProfiles.value = workers + searchViewModel._searchSubcategory.value = + Subcategory(tags = listOf("Exterior Painter", "Interior Painter", "Electrician", "Plumber")) + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testTagsAreDisplayed() { - // Set up the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + composeTestRule.onNodeWithTag("tuneButton").performClick() + // Scroll to the "Highest Rating" button in the LazyRow + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) - // Wait until the worker profiles are displayed - composeTestRule.waitForIdle() + // Click on the "Highest Rating" filter button + composeTestRule.onNodeWithText("Highest Rating").performClick() - // Click on the "Book" button of the first item - composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + composeTestRule.waitForIdle() - // Wait for the sliding window to appear - composeTestRule.waitForIdle() + // Verify that workers are sorted by rating in descending order + val sortedWorkers = workers.sortedByDescending { it.rating } + val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - // Verify the tags section is displayed - composeTestRule.onNodeWithTag("sliding_window_tags_flow_row").assertExists() - .assertIsDisplayed() + workerNodes.assertCountEquals(sortedWorkers.size) - // Check for each tag - val tags = listOf("Painter", "Gardener") + sortedWorkers.forEachIndexed { index, worker -> + workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + } + } - tags.forEach { tag -> - composeTestRule.onNodeWithText(tag).assertExists().assertIsDisplayed() - } + @Test + fun testCombinedFilters() { + val workers = + listOf( + WorkerProfile( + uid = "worker1", + displayName = "Worker One", + tags = listOf("Electrician"), + reviews = + ArrayDeque( + listOf( + Review(username = "User1", review = "Great service!", rating = 5.5))), + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker2", + displayName = "Worker Two", + tags = listOf("Electrician", "Plumber"), + reviews = + ArrayDeque( + listOf( + Review(username = "User1", review = "Great service!", rating = 4.8))), + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker3", + displayName = "Worker Three", + tags = listOf("Plumber"), + reviews = + ArrayDeque( + listOf( + Review(username = "User1", review = "Great service!", rating = 2.9))), + location = Location(40.7128, -74.0060))) + + // Provide test data to the searchViewModel + searchViewModel._subCategoryWorkerProfiles.value = workers + searchViewModel._searchSubcategory.value = + Subcategory(tags = listOf("Exterior Painter", "Interior Painter", "Electrician", "Plumber")) + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testSaveButtonTogglesBetweenSaveAndSaved() { - // Set up the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + // Apply Service Type filter + composeTestRule.onNodeWithText("Service Type").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Electrician").performClick() + composeTestRule.onNodeWithText("Apply").performClick() + composeTestRule.waitForIdle() - // Wait until the worker profiles are displayed - composeTestRule.waitForIdle() + // Scroll to the "Highest Rating" button in the LazyRow + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) - // Click on the "Book" button of the first item - composeTestRule.onAllNodesWithTag("book_button")[0].assertExists().performClick() + // Apply Highest Rating filter + composeTestRule.onNodeWithText("Highest Rating").performClick() + composeTestRule.waitForIdle() - // Wait for the sliding window to appear - composeTestRule.waitForIdle() + // Verify filtered workers + val filteredWorkers = + workers.filter { it.tags.contains("Electrician") }.sortedByDescending { it.rating } + val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - // Verify the "save" button is displayed - composeTestRule - .onNodeWithTag("sliding_window_save_button") - .assertExists() - .assertIsDisplayed() - .assertTextContains("save") + workerNodes.assertCountEquals(filteredWorkers.size) - // Click on the "save" button - composeTestRule.onNodeWithTag("sliding_window_save_button").performClick() + filteredWorkers.forEachIndexed { index, worker -> + workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + } + } - // Wait for the UI to update - composeTestRule.waitForIdle() + @Test + fun testNoMatchingWorkers() { + val workers = + listOf( + WorkerProfile( + uid = "worker1", + displayName = "Worker One", + tags = listOf("Electrician"), + rating = 4.5, + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker2", + displayName = "Worker Two", + tags = listOf("Electrician", "Plumber"), + rating = 4.8, + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker3", + displayName = "Worker Three", + tags = listOf("Plumber"), + rating = 2.9, + location = Location(40.7128, -74.0060))) + + // Provide test data to the searchViewModel + searchViewModel._subCategoryWorkerProfiles.value = workers + searchViewModel._searchSubcategory.value = + Subcategory( + tags = + listOf( + "Carpenter", "Exterior Painter", "Interior Painter", "Electrician", "Plumber")) + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Verify the button text changes to "saved" - composeTestRule - .onNodeWithTag("sliding_window_save_button") - .assertExists() - .assertIsDisplayed() - .assertTextContains("saved") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + + // Apply Service Type filter for a tag that doesn't exist + composeTestRule.onNodeWithText("Service Type").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Carpenter").performClick() // No workers with "Carpenter" tag + composeTestRule.onNodeWithText("Apply").performClick() + composeTestRule.waitForIdle() + + // Verify no workers are displayed + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(0) + } + + @Test + fun testPriceRangeFilterDisplaysBottomSheet() { + // Set the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Click again to toggle back to "save" - composeTestRule.onNodeWithTag("sliding_window_save_button").performClick() + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) + // Click on the "Price Range" filter button + composeTestRule.onNodeWithText("Price Range").performClick() - // Wait for the UI to update - composeTestRule.waitForIdle() + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() - // Verify the button text changes back to "save" - composeTestRule - .onNodeWithTag("sliding_window_save_button") - .assertExists() - .assertIsDisplayed() - .assertTextContains("save") - } + // Verify that the price range bottom sheet is displayed + composeTestRule.onNodeWithTag("priceRangeModalSheet").assertExists().assertIsDisplayed() + } - @Test - fun testWorkerFilteringByAvailabilityDays() { - // Set up test worker profiles with specific working hours and unavailability - val worker1 = + @Test + fun testPriceRangeFilterUpdatesResults() { + val workers = + listOf( WorkerProfile( uid = "worker1", + price = 150.0, fieldOfWork = "Painter", rating = 4.5, - workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), - unavailability_list = listOf(LocalDate.now()), - location = Location(40.7128, -74.0060) - ) - - val worker2 = + location = Location(40.7128, -74.0060)), WorkerProfile( uid = "worker2", + price = 560.0, fieldOfWork = "Electrician", - rating = 4.0, - workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - val worker3 = + rating = 4.8, + location = Location(40.7128, -74.0060)), WorkerProfile( uid = "worker3", + price = 3010.0, fieldOfWork = "Plumber", - rating = 5.0, - workingHours = Pair(LocalTime.of(10, 0), LocalTime.of(18, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - // Update the searchViewModel with these test workers - searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2, worker3) - - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - - // Initially, all workers should be displayed - composeTestRule.waitForIdle() - - // Verify that all 3 workers are displayed - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) - // Simulate clicking the "Availability" filter button - composeTestRule.onNodeWithText("Availability").performClick() - - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() - composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() - composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() - composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() - composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() - - val today = LocalDate.now() - val day = today.dayOfMonth - - val textFields = - composeTestRule.onAllNodes(hasSetTextAction()) - .filter(hasParent(hasTestTag("timeInput"))) + rating = 3.9, + location = Location(40.7128, -74.0060))) + + // Provide test data to the searchViewModel + searchViewModel._subCategoryWorkerProfiles.value = workers + + // Set the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Ensure that we have at least two text fields - assert(textFields.fetchSemanticsNodes().size >= 2) + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) + // Click on the "Price Range" filter button + composeTestRule.onNodeWithText("Price Range").performClick() - // Set the hour to "07" - textFields[0].performTextReplacement("10") + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() - // Set the minute to "00" - textFields[1].performTextReplacement("00") + composeTestRule.onNodeWithText("Apply").performClick() - // Find the node representing today's date and perform a click - composeTestRule - .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) - .performClick() + // Wait for the UI to update + composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("OK").performClick() + val sortedWorkers = listOf(workers[1]) + val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - composeTestRule.waitForIdle() + workerNodes.assertCountEquals(sortedWorkers.size) - // Verify that 2 workers are displayed - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) + sortedWorkers.forEachIndexed { index, worker -> + workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) } + } - @Test - fun testWorkerFilteringByAvailabilityHours() { - // Set up test worker profiles with specific working hours and unavailability - val worker1 = + @Test + fun testPriceRangeFilterExcludesWorkersOutsideRange() { + val workers = + listOf( WorkerProfile( uid = "worker1", + price = 150.0, fieldOfWork = "Painter", rating = 4.5, - workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - val worker2 = + location = Location(40.7128, -74.0060)), WorkerProfile( uid = "worker2", + price = 500.0, fieldOfWork = "Electrician", - rating = 4.0, - workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - val worker3 = + rating = 4.8, + location = Location(40.7128, -74.0060)), WorkerProfile( uid = "worker3", + price = 3001.0, fieldOfWork = "Plumber", - rating = 5.0, - workingHours = Pair(LocalTime.of(10, 0), LocalTime.of(18, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - // Update the searchViewModel with these test workers - searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2, worker3) - - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + rating = 3.9, + location = Location(40.7128, -74.0060))) + + // Provide test data to the searchViewModel + searchViewModel._subCategoryWorkerProfiles.value = workers + + // Set the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } + + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) + // Click on the "Price Range" filter button + composeTestRule.onNodeWithText("Price Range").performClick() + + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Apply").performClick() - // Initially, all workers should be displayed - composeTestRule.waitForIdle() + // Wait for the UI to update + composeTestRule.waitForIdle() - // Verify that all 3 workers are displayed - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) + val sortedWorkers = listOf(workers[1]) + val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) - // Simulate clicking the "Availability" filter button - composeTestRule.onNodeWithText("Availability").performClick() + workerNodes.assertCountEquals(sortedWorkers.size) + + sortedWorkers.forEachIndexed { index, worker -> + workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + } + } + + @Test + fun testLocationFilterApplyAndClear() { + // Set up test workers with various locations + val workers = + listOf( + WorkerProfile( + uid = "worker1", + location = Location(40.0, -74.0, "Home"), + fieldOfWork = "Painter", + rating = 4.5), + WorkerProfile( + uid = "worker2", + location = Location(45.0, -75.0, "Far"), + fieldOfWork = "Electrician", + rating = 4.0)) + + // Provide test data to the searchViewModel + searchViewModel._subCategoryWorkerProfiles.value = workers + + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() + // Initially, both workers should be displayed + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() - composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() - composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() - composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() - composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() + composeTestRule.onNodeWithTag("tuneButton").performClick() + // Scroll to the "Location" button in the LazyRow if needed + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - val today = LocalDate.now() - val day = today.dayOfMonth + // Open the Location filter bottom sheet + composeTestRule.onNodeWithText("Location").performClick() + composeTestRule.waitForIdle() - val textFields = - composeTestRule.onAllNodes(hasSetTextAction()) - .filter(hasParent(hasTestTag("timeInput"))) + // Verify bottom sheet is displayed + composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() - // Ensure that we have at least two text fields - assert(textFields.fetchSemanticsNodes().size >= 2) + // Select "Home" location + composeTestRule.onNodeWithTag("locationOptionRow1").performClick() - // Set the hour to "07" - textFields[0].performTextReplacement("08") + // Click Apply + composeTestRule.onNodeWithTag("applyButton").performClick() + composeTestRule.waitForIdle() - // Set the minute to "00" - textFields[1].performTextReplacement("00") + // Verify that only the worker at "Home" is displayed (worker1) + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - // Find the node representing today's date and perform a click - composeTestRule - .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) - .performClick() + // Open Location filter again to clear + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + composeTestRule.onNodeWithText("Location").performClick() + composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("OK").performClick() + // Verify bottom sheet + composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() - composeTestRule.waitForIdle() + // Clear the filter + composeTestRule.onNodeWithTag("resetButton").performClick() + composeTestRule.waitForIdle() - // Verify that one worker is displayed - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - } + // Verify that we are back to the initial state (2 workers displayed) + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) + } - @Test - fun testWorkerFilteringByAvailabilityMinutes() { - // Set up test worker profiles with specific working hours and unavailability - val worker1 = + @Test + fun testClearingOneFilterWhileKeepingOthers() { + val workers = + listOf( WorkerProfile( uid = "worker1", fieldOfWork = "Painter", rating = 4.5, - workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - val worker2 = + location = Location(40.0, -74.0, "Home"), + tags = listOf("Interior Painter")), WorkerProfile( uid = "worker2", fieldOfWork = "Electrician", rating = 4.0, - workingHours = Pair(LocalTime.of(8, 30), LocalTime.of(16, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - val worker3 = + location = Location(45.0, -75.0, "Far"), + tags = listOf("Electrician")), WorkerProfile( uid = "worker3", fieldOfWork = "Plumber", - rating = 5.0, - workingHours = Pair(LocalTime.of(10, 0), LocalTime.of(18, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - // Update the searchViewModel with these test workers - searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2, worker3) - - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - - // Initially, all workers should be displayed - composeTestRule.waitForIdle() - - // Verify that all 3 workers are displayed - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) - // Simulate clicking the "Availability" filter button - composeTestRule.onNodeWithText("Availability").performClick() - - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() - composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() - composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() - composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() - composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() - - val today = LocalDate.now() - val day = today.dayOfMonth + rating = 3.5, + location = Location(42.0, -74.5, "Work"), + tags = listOf("Plumber"))) + + searchViewModel._subCategoryWorkerProfiles.value = workers + searchViewModel._searchSubcategory.value = + Subcategory(tags = listOf("Interior Painter", "Electrician", "Plumber")) + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - val textFields = - composeTestRule.onAllNodes(hasSetTextAction()) - .filter(hasParent(hasTestTag("timeInput"))) + composeTestRule.waitForIdle() + // Initially, all 3 workers + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) + + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + // Apply Service Type filter = "Interior Painter" + composeTestRule.onNodeWithText("Service Type").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Interior Painter").performClick() + composeTestRule.onNodeWithText("Apply").performClick() + composeTestRule.waitForIdle() + + // Now only worker1 matches + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) + + // Apply Location filter to get even more specific (Assume "Home") + composeTestRule + .onNodeWithTag("filter_buttons_row") + .performScrollToIndex(1) // scroll to "Location" if needed + composeTestRule.onNodeWithText("Location").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Home").performClick() + composeTestRule.onNodeWithTag("applyButton").performClick() + composeTestRule.waitForIdle() + + // Still only worker1 (since it was the only one anyway) + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) + + // Now clear the Location filter but keep the Service Type filter + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + composeTestRule.onNodeWithText("Location").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("resetButton").performClick() + composeTestRule.waitForIdle() + + // After clearing Location, we should still have only the Service Type filter applied + // That means still only worker1 should be visible + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) + } + + @Test + fun testClearAvailabilityFilter() { + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + unavailability_list = listOf(LocalDate.now()), + location = Location(40.7128, -74.0060)) + val worker2 = + WorkerProfile( + uid = "worker2", + fieldOfWork = "Electrician", + rating = 4.0, + workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), + unavailability_list = emptyList(), + location = Location(40.7128, -74.0060)) + + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2) + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Ensure that we have at least two text fields - assert(textFields.fetchSemanticsNodes().size >= 2) + composeTestRule.waitForIdle() + // Initially 2 workers + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - // Set the hour to "07" - textFields[0].performTextReplacement("08") + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) + // Apply Availability filter for today at 10:00 (both should be available) + composeTestRule.onNodeWithText("Availability").performClick() + composeTestRule.waitForIdle() - // Set the minute to "00" - textFields[1].performTextReplacement("00") + val today = LocalDate.now().dayOfMonth + val textFields = + composeTestRule.onAllNodes(hasSetTextAction()).filter(hasParent(hasTestTag("timeInput"))) - // Find the node representing today's date and perform a click - composeTestRule - .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) - .performClick() + // Ensure that we have at least two text fields + assert(textFields.fetchSemanticsNodes().size >= 2) - composeTestRule.onNodeWithText("OK").performClick() + textFields[0].performTextReplacement("10") - composeTestRule.waitForIdle() + textFields[1].performTextReplacement("00") - // Verify that no workers are displayed - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(0) - } - - @Test - fun testWorkerFilteringByServices() { - val workers = - listOf( - WorkerProfile( - uid = "worker1", - tags = listOf("Exterior Painter", "Interior Painter"), - rating = 4.5, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker2", - tags = listOf("Interior Painter", "Electrician"), - rating = 4.0, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker3", - tags = listOf("Plumber"), - rating = 5.0, - location = Location(40.7128, -74.0060) - ) - ) - - // Set up subcategory tags - searchViewModel._searchSubcategory.value = - Subcategory( - tags = listOf( - "Exterior Painter", - "Interior Painter", - "Electrician", - "Plumber" - ) - ) - - searchViewModel._subCategoryWorkerProfiles.value = workers - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Find the node representing today's date and perform a click + composeTestRule + .onNode(hasText(today.toString()) and hasClickAction() and !hasSetTextAction()) + .performClick() - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - // Click on the "Service Type" filter button - composeTestRule.onNodeWithText("Service Type").performClick() + composeTestRule.onNodeWithText("OK").performClick() - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertIsDisplayed() + // Clear the Availability filter + composeTestRule.onNodeWithText("Availability").performClick() + composeTestRule.waitForIdle() + composeTestRule + .onAllNodes(hasText("Clear")) + .filter(!hasTestTag("filter_button_Clear"))[0] + .performClick() + composeTestRule.waitForIdle() - // Simulate selecting "Interior Painter" - composeTestRule.onNodeWithText("Interior Painter").performClick() - composeTestRule.onNodeWithText("Apply").performClick() + // With availability cleared and no other filters applied, we should still see 2 workers + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) + } - composeTestRule.waitForIdle() - - // Verify filtered workers - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - } - - @Test - fun testWorkerSortingByRating() { - val workers = - listOf( - WorkerProfile( - uid = "worker1", - displayName = "Worker One", - tags = listOf("Electrician"), - reviews = + @Test + fun testTogglingRatingFilterOff() { + val workers = + listOf( + WorkerProfile( + uid = "w1", + reviews = ArrayDeque( listOf( - Review(username = "User1", review = "Great service!", rating = 3.5) - ) - ), - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker2", - displayName = "Worker Two", - tags = listOf("Electrician"), - reviews = + Review(username = "User1", review = "Great service!", rating = 3.0))), + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "w2", + reviews = ArrayDeque( listOf( - Review(username = "User1", review = "Great service!", rating = 4.8) - ) - ), - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker3", - displayName = "Worker Three", - tags = listOf("Electrician"), - reviews = + Review(username = "User1", review = "Great service!", rating = 4.5))), + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "w3", + reviews = ArrayDeque( listOf( - Review(username = "User1", review = "Great service!", rating = 2.9) - ) - ), - location = Location(40.7128, -74.0060) - ) - ) - - // Provide test data to the searchViewModel - searchViewModel._subCategoryWorkerProfiles.value = workers - searchViewModel._searchSubcategory.value = - Subcategory( - tags = listOf( - "Exterior Painter", - "Interior Painter", - "Electrician", - "Plumber" - ) - ) - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + Review(username = "User1", review = "Great service!", rating = 2.0))), + location = Location(40.7128, -74.0060))) + + searchViewModel._subCategoryWorkerProfiles.value = workers + // Initially, no rating filter applied, workers are in initial order + // We'll toggle the rating filter on, then off. + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - composeTestRule.onNodeWithTag("tuneButton").performClick() - // Scroll to the "Highest Rating" button in the LazyRow - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) + composeTestRule.waitForIdle() + // Show filter buttons + composeTestRule.onNodeWithTag("tuneButton").performClick() + + // Apply Highest Rating filter + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) + composeTestRule.onNodeWithText("Highest Rating").performClick() + composeTestRule.waitForIdle() + + // Now workers should be sorted by rating descending: w2(4.5), w1(3.0), w3(2.0) + val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() + workerNodes.assertCountEquals(workers.size) + // Verify order by rating text + workerNodes[0].assert(hasText("4.5 ★", substring = true)) + workerNodes[1].assert(hasText("3.0 ★", substring = true)) + workerNodes[2].assert(hasText("2.0 ★", substring = true)) + + // Click again to remove Highest Rating filter + composeTestRule.onNodeWithText("Highest Rating").performClick() + composeTestRule.waitForIdle() + + // With the rating filter removed, `reapplyFilters()` is called, and no filters are applied. + // The default implementation should revert to the original order (the order in + // `_subCategoryWorkerProfiles`). + // Check that the initial worker (w1) is now first again. + + val workerNodesAfterRevert = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() + workerNodesAfterRevert[0].assert(hasText("3.0 ★", substring = true)) // w1 first again + workerNodesAfterRevert[1].assert(hasText("4.5 ★", substring = true)) + workerNodesAfterRevert[2].assert(hasText("2.0 ★", substring = true)) + } + + @Test + fun testTogglingFilterButtonsVisibility() { + // Set some workers just so the UI loads normally + searchViewModel._subCategoryWorkerProfiles.value = listOf(WorkerProfile(uid = "test")) + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - // Click on the "Highest Rating" filter button - composeTestRule.onNodeWithText("Highest Rating").performClick() + composeTestRule.waitForIdle() + + // Initially, the filter_buttons_row might not be visible until we click the tune button + composeTestRule.onNodeWithTag("filter_buttons_row").assertDoesNotExist() + + // Click the tune button to show filter buttons + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() + + // Click the tune button again to hide filter buttons + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("filter_buttons_row").assertDoesNotExist() + } + + @Test + fun testServiceTypeSheetNotShownWhenSubcategoryIsNull() { + // No subcategory set + searchViewModel._searchSubcategory.value = null + // Workers to display something + searchViewModel._subCategoryWorkerProfiles.value = listOf(WorkerProfile(uid = "w1")) + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) + } - composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + // Show filter buttons + composeTestRule.onNodeWithTag("tuneButton").performClick() - // Verify that workers are sorted by rating in descending order - val sortedWorkers = workers.sortedByDescending { it.rating } - val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() + // Attempt to open the Service Type filter + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + composeTestRule.onNodeWithText("Service Type").performClick() - workerNodes.assertCountEquals(sortedWorkers.size) + composeTestRule.waitForIdle() - sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert( - hasText("${worker.price.roundToInt()}", substring = true) - ) - } - } + // Since searchSubcategory is null, ChooseServiceTypeSheet should not appear + composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertDoesNotExist() + } - @Test - fun testCombinedFilters() { - val workers = - listOf( - WorkerProfile( - uid = "worker1", - displayName = "Worker One", - tags = listOf("Electrician"), - reviews = - ArrayDeque( - listOf( - Review(username = "User1", review = "Great service!", rating = 5.5) - ) - ), - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker2", - displayName = "Worker Two", - tags = listOf("Electrician", "Plumber"), - reviews = - ArrayDeque( - listOf( - Review(username = "User1", review = "Great service!", rating = 4.8) - ) - ), - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker3", - displayName = "Worker Three", - tags = listOf("Plumber"), - reviews = - ArrayDeque( - listOf( - Review(username = "User1", review = "Great service!", rating = 2.9) - ) - ), - location = Location(40.7128, -74.0060) - ) - ) - - // Provide test data to the searchViewModel - searchViewModel._subCategoryWorkerProfiles.value = workers - searchViewModel._searchSubcategory.value = - Subcategory( - tags = listOf( - "Exterior Painter", - "Interior Painter", - "Electrician", - "Plumber" - ) - ) - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - // Apply Service Type filter - composeTestRule.onNodeWithText("Service Type").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Electrician").performClick() - composeTestRule.onNodeWithText("Apply").performClick() - composeTestRule.waitForIdle() - - // Scroll to the "Highest Rating" button in the LazyRow - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) - - // Apply Highest Rating filter - composeTestRule.onNodeWithText("Highest Rating").performClick() - composeTestRule.waitForIdle() - - // Verify filtered workers - val filteredWorkers = - workers.filter { it.tags.contains("Electrician") }.sortedByDescending { it.rating } - val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - - workerNodes.assertCountEquals(filteredWorkers.size) - - filteredWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert( - hasText("${worker.price.roundToInt()}", substring = true) - ) - } + @Test + fun testWorkerFilteringByServicesTwiceBehavesCorrectly() { + val workers = + listOf( + WorkerProfile( + uid = "worker1", + tags = listOf("Exterior Painter", "Interior Painter"), + rating = 4.5, + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker2", + tags = listOf("Interior Painter", "Electrician"), + rating = 4.0, + location = Location(40.7128, -74.0060)), + WorkerProfile( + uid = "worker3", + price = 777.0, + tags = listOf("Plumber"), + rating = 5.0, + location = Location(40.7128, -74.0060))) + + // Set up subcategory tags + searchViewModel._searchSubcategory.value = + Subcategory(tags = listOf("Exterior Painter", "Interior Painter", "Plumber", "Electrician")) + + searchViewModel._subCategoryWorkerProfiles.value = workers + + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testNoMatchingWorkers() { - val workers = - listOf( - WorkerProfile( - uid = "worker1", - displayName = "Worker One", - tags = listOf("Electrician"), - rating = 4.5, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker2", - displayName = "Worker Two", - tags = listOf("Electrician", "Plumber"), - rating = 4.8, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker3", - displayName = "Worker Three", - tags = listOf("Plumber"), - rating = 2.9, - location = Location(40.7128, -74.0060) - ) - ) - - // Provide test data to the searchViewModel - searchViewModel._subCategoryWorkerProfiles.value = workers - searchViewModel._searchSubcategory.value = - Subcategory( - tags = - listOf( - "Carpenter", "Exterior Painter", "Interior Painter", "Electrician", "Plumber" - ) - ) - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + // Click on the "Service Type" filter button + composeTestRule.onNodeWithText("Service Type").performClick() - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() - // Apply Service Type filter for a tag that doesn't exist - composeTestRule.onNodeWithText("Service Type").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Carpenter") - .performClick() // No workers with "Carpenter" tag - composeTestRule.onNodeWithText("Apply").performClick() - composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertIsDisplayed() - // Verify no workers are displayed - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(0) - } + // Simulate selecting "Interior Painter" + composeTestRule.onNodeWithText("Interior Painter").performClick() + composeTestRule.onNodeWithText("Apply").performClick() - @Test - fun testPriceRangeFilterDisplaysBottomSheet() { - // Set the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) - // Click on the "Price Range" filter button - composeTestRule.onNodeWithText("Price Range").performClick() + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Service Type").performClick() - // Verify that the price range bottom sheet is displayed - composeTestRule.onNodeWithTag("priceRangeModalSheet").assertExists().assertIsDisplayed() - } + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() - @Test - fun testPriceRangeFilterUpdatesResults() { - val workers = - listOf( - WorkerProfile( - uid = "worker1", - price = 150.0, - fieldOfWork = "Painter", - rating = 4.5, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker2", - price = 560.0, - fieldOfWork = "Electrician", - rating = 4.8, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker3", - price = 3010.0, - fieldOfWork = "Plumber", - rating = 3.9, - location = Location(40.7128, -74.0060) - ) - ) - - // Provide test data to the searchViewModel - searchViewModel._subCategoryWorkerProfiles.value = workers - - // Set the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) - // Click on the "Price Range" filter button - composeTestRule.onNodeWithText("Price Range").performClick() + composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertIsDisplayed() - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() + // Simulate selecting "Interior Painter" + composeTestRule.onNodeWithText("Interior Painter").performClick() + composeTestRule.onNodeWithText("Plumber").performClick() + composeTestRule.onNodeWithText("Apply").performClick() - composeTestRule.onNodeWithText("Apply").performClick() + composeTestRule.waitForIdle() - // Wait for the UI to update - composeTestRule.waitForIdle() + // Verify filtered workers + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - val sortedWorkers = listOf(workers[1]) - val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() + val sortedWorkers = listOf(workers[2]) + val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - workerNodes.assertCountEquals(sortedWorkers.size) + workerNodes.assertCountEquals(sortedWorkers.size) - sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert( - hasText("${worker.price.roundToInt()}", substring = true) - ) - } + sortedWorkers.forEachIndexed { index, worker -> + workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + } + } + + @Test + fun testWorkerFilteringByAvailabilityTwiceBehavesCorrectly() { + // Set up test worker profiles with specific working hours and unavailability + val worker1 = + WorkerProfile( + uid = "worker1", + fieldOfWork = "Painter", + rating = 4.5, + workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), + unavailability_list = listOf(LocalDate.now()), + location = Location(40.7128, -74.0060)) + + val worker2 = + WorkerProfile( + uid = "worker2", + fieldOfWork = "Electrician", + rating = 4.0, + workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), + unavailability_list = listOf(LocalDate.now().plusDays(1)), + location = Location(40.7128, -74.0060)) + + val worker3 = + WorkerProfile( + uid = "worker3", + fieldOfWork = "Plumber", + rating = 5.0, + workingHours = Pair(LocalTime.of(10, 0), LocalTime.of(18, 0)), + unavailability_list = listOf(LocalDate.now()), + location = Location(40.7128, -74.0060)) + + // Update the searchViewModel with these test workers + searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2, worker3) + + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testPriceRangeFilterExcludesWorkersOutsideRange() { - val workers = - listOf( - WorkerProfile( - uid = "worker1", - price = 150.0, - fieldOfWork = "Painter", - rating = 4.5, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker2", - price = 500.0, - fieldOfWork = "Electrician", - rating = 4.8, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker3", - price = 3001.0, - fieldOfWork = "Plumber", - rating = 3.9, - location = Location(40.7128, -74.0060) - ) - ) - - // Provide test data to the searchViewModel - searchViewModel._subCategoryWorkerProfiles.value = workers - - // Set the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Initially, all workers should be displayed + composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(4) - // Click on the "Price Range" filter button - composeTestRule.onNodeWithText("Price Range").performClick() + // Verify that all 3 workers are displayed + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) + // Simulate clicking the "Availability" filter button + composeTestRule.onNodeWithText("Availability").performClick() - composeTestRule.onNodeWithText("Apply").performClick() + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() - // Wait for the UI to update - composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() + composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() + composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() + composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() - val sortedWorkers = listOf(workers[1]) - val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() + val today = LocalDate.now() + val day = today.dayOfMonth - workerNodes.assertCountEquals(sortedWorkers.size) + val textFields = + composeTestRule.onAllNodes(hasSetTextAction()).filter(hasParent(hasTestTag("timeInput"))) - sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert( - hasText("${worker.price.roundToInt()}", substring = true) - ) - } - } + // Ensure that we have at least two text fields + assert(textFields.fetchSemanticsNodes().size >= 2) - @Test - fun testLocationFilterApplyAndClear() { - // Set up test workers with various locations - val workers = - listOf( - WorkerProfile( - uid = "worker1", - location = Location(40.0, -74.0, "Home"), - fieldOfWork = "Painter", - rating = 4.5 - ), - WorkerProfile( - uid = "worker2", - location = Location(45.0, -75.0, "Far"), - fieldOfWork = "Electrician", - rating = 4.0 - ) - ) - - // Provide test data to the searchViewModel - searchViewModel._subCategoryWorkerProfiles.value = workers - - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Set the hour to "07" + textFields[0].performTextReplacement("10") - // Initially, both workers should be displayed - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) + // Set the minute to "00" + textFields[1].performTextReplacement("00") - composeTestRule.onNodeWithTag("tuneButton").performClick() - // Scroll to the "Location" button in the LazyRow if needed - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + // Find the node representing today's date and perform a click + composeTestRule + .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) + .performClick() - // Open the Location filter bottom sheet - composeTestRule.onNodeWithText("Location").performClick() - composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("OK").performClick() - // Verify bottom sheet is displayed - composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() + composeTestRule.waitForIdle() - // Select "Home" location - composeTestRule.onNodeWithTag("locationOptionRow1").performClick() + // Verify that 2 workers are displayed + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - // Click Apply - composeTestRule.onNodeWithTag("applyButton").performClick() - composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Availability").performClick() - // Verify that only the worker at "Home" is displayed (worker1) - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) + composeTestRule.waitForIdle() - // Open Location filter again to clear - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - composeTestRule.onNodeWithText("Location").performClick() - composeTestRule.waitForIdle() + val textFields2 = + composeTestRule.onAllNodes(hasSetTextAction()).filter(hasParent(hasTestTag("timeInput"))) - // Verify bottom sheet - composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() + // Ensure that we have at least two text fields + assert(textFields2.fetchSemanticsNodes().size >= 2) - // Clear the filter - composeTestRule.onNodeWithTag("resetButton").performClick() - composeTestRule.waitForIdle() + // Set the hour to "07" + textFields2[0].performTextReplacement("10") - // Verify that we are back to the initial state (2 workers displayed) - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - } + // Set the minute to "00" + textFields2[1].performTextReplacement("00") - @Test - fun testClearingOneFilterWhileKeepingOthers() { - val workers = - listOf( - WorkerProfile( - uid = "worker1", - fieldOfWork = "Painter", - rating = 4.5, - location = Location(40.0, -74.0, "Home"), - tags = listOf("Interior Painter") - ), - WorkerProfile( - uid = "worker2", - fieldOfWork = "Electrician", - rating = 4.0, - location = Location(45.0, -75.0, "Far"), - tags = listOf("Electrician") - ), - WorkerProfile( - uid = "worker3", - fieldOfWork = "Plumber", - rating = 3.5, - location = Location(42.0, -74.5, "Work"), - tags = listOf("Plumber") - ) - ) - - searchViewModel._subCategoryWorkerProfiles.value = workers - searchViewModel._searchSubcategory.value = - Subcategory(tags = listOf("Interior Painter", "Electrician", "Plumber")) - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Find the node representing today's date and perform a click + composeTestRule + .onNode( + hasText(LocalDate.now().dayOfMonth.toString()) and + hasClickAction() and + !hasSetTextAction()) + .performClick() - composeTestRule.waitForIdle() - // Initially, all 3 workers - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - // Apply Service Type filter = "Interior Painter" - composeTestRule.onNodeWithText("Service Type").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Interior Painter").performClick() - composeTestRule.onNodeWithText("Apply").performClick() - composeTestRule.waitForIdle() - - // Now only worker1 matches - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - - // Apply Location filter to get even more specific (Assume "Home") - composeTestRule - .onNodeWithTag("filter_buttons_row") - .performScrollToIndex(1) // scroll to "Location" if needed - composeTestRule.onNodeWithText("Location").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Home").performClick() - composeTestRule.onNodeWithTag("applyButton").performClick() - composeTestRule.waitForIdle() - - // Still only worker1 (since it was the only one anyway) - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - - // Now clear the Location filter but keep the Service Type filter - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - composeTestRule.onNodeWithText("Location").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("resetButton").performClick() - composeTestRule.waitForIdle() - - // After clearing Location, we should still have only the Service Type filter applied - // That means still only worker1 should be visible - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - } + composeTestRule.onNodeWithText("OK").performClick() + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) + } - @Test - fun testClearAvailabilityFilter() { - val worker1 = + @Test + fun testLocationFilterReselectBehavesCorrectly() { + // Set up test workers with various locations + val workers = + listOf( WorkerProfile( uid = "worker1", + location = Location(40.0, -74.0, "Home"), fieldOfWork = "Painter", - rating = 4.5, - workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), - unavailability_list = listOf(LocalDate.now()), - location = Location(40.7128, -74.0060) - ) - val worker2 = + rating = 4.5), WorkerProfile( uid = "worker2", + location = Location(45.0, -75.0, "Far"), fieldOfWork = "Electrician", - rating = 4.0, - workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), - unavailability_list = emptyList(), - location = Location(40.7128, -74.0060) - ) - - searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2) - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - - composeTestRule.waitForIdle() - // Initially 2 workers - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) - // Apply Availability filter for today at 10:00 (both should be available) - composeTestRule.onNodeWithText("Availability").performClick() - composeTestRule.waitForIdle() - - val today = LocalDate.now().dayOfMonth - val textFields = - composeTestRule.onAllNodes(hasSetTextAction()) - .filter(hasParent(hasTestTag("timeInput"))) - - // Ensure that we have at least two text fields - assert(textFields.fetchSemanticsNodes().size >= 2) - - textFields[0].performTextReplacement("10") - - textFields[1].performTextReplacement("00") - - // Find the node representing today's date and perform a click - composeTestRule - .onNode(hasText(today.toString()) and hasClickAction() and !hasSetTextAction()) - .performClick() - - composeTestRule.onNodeWithText("OK").performClick() - - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - - // Clear the Availability filter - composeTestRule.onNodeWithText("Availability").performClick() - composeTestRule.waitForIdle() - composeTestRule - .onAllNodes(hasText("Clear")) - .filter(!hasTestTag("filter_button_Clear"))[0] - .performClick() - composeTestRule.waitForIdle() - - // With availability cleared and no other filters applied, we should still see 2 workers - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) + rating = 4.0)) + + // Provide test data to the searchViewModel + searchViewModel._subCategoryWorkerProfiles.value = workers + + // Set the composable content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel) } - @Test - fun testTogglingRatingFilterOff() { - val workers = - listOf( - WorkerProfile( - uid = "w1", - reviews = - ArrayDeque( - listOf( - Review(username = "User1", review = "Great service!", rating = 3.0) - ) - ), - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "w2", - reviews = - ArrayDeque( - listOf( - Review(username = "User1", review = "Great service!", rating = 4.5) - ) - ), - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "w3", - reviews = - ArrayDeque( - listOf( - Review(username = "User1", review = "Great service!", rating = 2.0) - ) - ), - location = Location(40.7128, -74.0060) - ) - ) - - searchViewModel._subCategoryWorkerProfiles.value = workers - // Initially, no rating filter applied, workers are in initial order - // We'll toggle the rating filter on, then off. - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Initially, both workers should be displayed + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - composeTestRule.waitForIdle() - // Show filter buttons - composeTestRule.onNodeWithTag("tuneButton").performClick() - - // Apply Highest Rating filter - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) - composeTestRule.onNodeWithText("Highest Rating").performClick() - composeTestRule.waitForIdle() - - // Now workers should be sorted by rating descending: w2(4.5), w1(3.0), w3(2.0) - val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - workerNodes.assertCountEquals(workers.size) - // Verify order by rating text - workerNodes[0].assert(hasText("4.5 ★", substring = true)) - workerNodes[1].assert(hasText("3.0 ★", substring = true)) - workerNodes[2].assert(hasText("2.0 ★", substring = true)) - - // Click again to remove Highest Rating filter - composeTestRule.onNodeWithText("Highest Rating").performClick() - composeTestRule.waitForIdle() - - // With the rating filter removed, `reapplyFilters()` is called, and no filters are applied. - // The default implementation should revert to the original order (the order in - // `_subCategoryWorkerProfiles`). - // Check that the initial worker (w1) is now first again. - - val workerNodesAfterRevert = - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - workerNodesAfterRevert[0].assert(hasText("3.0 ★", substring = true)) // w1 first again - workerNodesAfterRevert[1].assert(hasText("4.5 ★", substring = true)) - workerNodesAfterRevert[2].assert(hasText("2.0 ★", substring = true)) - } + composeTestRule.onNodeWithTag("tuneButton").performClick() + // Scroll to the "Location" button in the LazyRow if needed + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - @Test - fun testTogglingFilterButtonsVisibility() { - // Set some workers just so the UI loads normally - searchViewModel._subCategoryWorkerProfiles.value = listOf(WorkerProfile(uid = "test")) - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Open the Location filter bottom sheet + composeTestRule.onNodeWithText("Location").performClick() + composeTestRule.waitForIdle() - composeTestRule.waitForIdle() + // Verify bottom sheet is displayed + composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() - // Initially, the filter_buttons_row might not be visible until we click the tune button - composeTestRule.onNodeWithTag("filter_buttons_row").assertDoesNotExist() + composeTestRule.onNodeWithTag("applyButton").assertIsNotEnabled() - // Click the tune button to show filter buttons - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").assertIsDisplayed() + // Select "Home" location + composeTestRule.onNodeWithTag("locationOptionRow1").performClick() - // Click the tune button again to hide filter buttons - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("filter_buttons_row").assertDoesNotExist() - } + // Click Apply + composeTestRule.onNodeWithTag("applyButton").performClick() - @Test - fun testServiceTypeSheetNotShownWhenSubcategoryIsNull() { - // No subcategory set - searchViewModel._searchSubcategory.value = null - // Workers to display something - searchViewModel._subCategoryWorkerProfiles.value = listOf(WorkerProfile(uid = "w1")) - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } + // Verify that only the worker at "Home" is displayed (worker1) + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - composeTestRule.waitForIdle() - // Show filter buttons - composeTestRule.onNodeWithTag("tuneButton").performClick() + // Open Location filter again to clear + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) + composeTestRule.onNodeWithText("Location").performClick() + composeTestRule.waitForIdle() - // Attempt to open the Service Type filter - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - composeTestRule.onNodeWithText("Service Type").performClick() + // Verify bottom sheet + composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() - composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("applyButton").assertIsEnabled() - // Since searchSubcategory is null, ChooseServiceTypeSheet should not appear - composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertDoesNotExist() - } + composeTestRule.onNodeWithTag("locationOptionRow0").performClick() - @Test - fun testWorkerFilteringByServicesTwiceBehavesCorrectly() { - val workers = - listOf( - WorkerProfile( - uid = "worker1", - tags = listOf("Exterior Painter", "Interior Painter"), - rating = 4.5, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker2", - tags = listOf("Interior Painter", "Electrician"), - rating = 4.0, - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker3", - price = 777.0, - tags = listOf("Plumber"), - rating = 5.0, - location = Location(40.7128, -74.0060) - ) - ) - - // Set up subcategory tags - searchViewModel._searchSubcategory.value = - Subcategory( - tags = listOf( - "Exterior Painter", - "Interior Painter", - "Plumber", - "Electrician" - ) - ) - - searchViewModel._subCategoryWorkerProfiles.value = workers - - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - // Click on the "Service Type" filter button - composeTestRule.onNodeWithText("Service Type").performClick() - - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertIsDisplayed() + // Clear the filter + composeTestRule.onNodeWithTag("resetButton").performClick() + composeTestRule.waitForIdle() - // Simulate selecting "Interior Painter" - composeTestRule.onNodeWithText("Interior Painter").performClick() - composeTestRule.onNodeWithText("Apply").performClick() + // Verify that we are back to the initial state (2 workers displayed) + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Location").performClick() + composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) + // Verify bottom sheet + composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() - composeTestRule.onNodeWithText("Service Type").performClick() + composeTestRule.onNodeWithTag("applyButton").assertIsNotEnabled() + composeTestRule.onNodeWithTag("resetButton").assertIsNotEnabled() + } - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() + @Test + fun testEmergencyUpdatesResults() { + context = mock(Context::class.java) + activity = mock(Activity::class.java) + fusedLocationProviderClient = mock(FusedLocationProviderClient::class.java) + // Create a spy of LocationHelper + locationHelper = spy(LocationHelper(context, activity, fusedLocationProviderClient)) - composeTestRule.onNodeWithTag("chooseServiceTypeModalSheet").assertIsDisplayed() + `when`(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) + .thenReturn(PackageManager.PERMISSION_GRANTED) + `when`(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)) + .thenReturn(PackageManager.PERMISSION_GRANTED) - // Simulate selecting "Interior Painter" - composeTestRule.onNodeWithText("Interior Painter").performClick() - composeTestRule.onNodeWithText("Plumber").performClick() - composeTestRule.onNodeWithText("Apply").performClick() + // Mock location enabled + val locationManager = mock(LocationManager::class.java) + `when`(context.getSystemService(Context.LOCATION_SERVICE)).thenReturn(locationManager) + `when`(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)).thenReturn(true) + `when`(locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)).thenReturn(true) - composeTestRule.waitForIdle() + // Mock permissions check + `when`(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) + .thenReturn(PackageManager.PERMISSION_GRANTED) + `when`(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)) + .thenReturn(PackageManager.PERMISSION_GRANTED) - // Verify filtered workers - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) + // Mock fusedLocationProviderClient.lastLocation + val mockLocation = mock(android.location.Location::class.java) + `when`(mockLocation.latitude).thenReturn(0.0) + `when`(mockLocation.longitude).thenReturn(0.0) - val sortedWorkers = listOf(workers[2]) - val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() + val mockTask = mock(Task::class.java) as Task + `when`(mockTask.isSuccessful).thenReturn(true) + `when`(mockTask.result).thenReturn(mockLocation) + `when`(fusedLocationProviderClient.lastLocation).thenReturn(mockTask) - workerNodes.assertCountEquals(sortedWorkers.size) - - sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert( - hasText("${worker.price.roundToInt()}", substring = true) - ) - } + // Mock addOnCompleteListener + `when`(mockTask.addOnCompleteListener(Mockito.any())).thenAnswer { invocation -> + val listener = invocation.arguments[0] as OnCompleteListener + listener.onComplete(mockTask) + mockTask } - @Test - fun testWorkerFilteringByAvailabilityTwiceBehavesCorrectly() { - // Set up test worker profiles with specific working hours and unavailability - val worker1 = + val workers = + listOf( WorkerProfile( uid = "worker1", + price = 150.0, fieldOfWork = "Painter", rating = 4.5, - workingHours = Pair(LocalTime.of(9, 0), LocalTime.of(17, 0)), - unavailability_list = listOf(LocalDate.now()), - location = Location(40.7128, -74.0060) - ) - - val worker2 = + workingHours = Pair(LocalTime.of(0, 0), LocalTime.of(23, 59)), + location = Location(45.0, -75.0)), WorkerProfile( uid = "worker2", + price = 560.0, fieldOfWork = "Electrician", - rating = 4.0, - workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)), - unavailability_list = listOf(LocalDate.now().plusDays(1)), - location = Location(40.7128, -74.0060) - ) - - val worker3 = + rating = 4.8, + workingHours = Pair(LocalTime.of(0, 0), LocalTime.of(23, 59)), + location = Location(40.7128, -74.0060)), WorkerProfile( uid = "worker3", + price = 600.0, fieldOfWork = "Plumber", - rating = 5.0, - workingHours = Pair(LocalTime.of(10, 0), LocalTime.of(18, 0)), - unavailability_list = listOf(LocalDate.now()), - location = Location(40.7128, -74.0060) - ) - - // Update the searchViewModel with these test workers - searchViewModel._subCategoryWorkerProfiles.value = listOf(worker1, worker2, worker3) - - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - - // Initially, all workers should be displayed - composeTestRule.waitForIdle() - - // Verify that all 3 workers are displayed - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(3) - // Simulate clicking the "Availability" filter button - composeTestRule.onNodeWithText("Availability").performClick() - - // Wait for the bottom sheet to appear - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithTag("availabilityBottomSheet").assertIsDisplayed() - composeTestRule.onNodeWithTag("timePickerColumn").assertIsDisplayed() - composeTestRule.onNodeWithTag("timeInput").assertIsDisplayed() - composeTestRule.onNodeWithTag("bottomSheetColumn").assertIsDisplayed() - composeTestRule.onNodeWithText("Enter time").assertIsDisplayed() - - val today = LocalDate.now() - val day = today.dayOfMonth - - val textFields = - composeTestRule.onAllNodes(hasSetTextAction()) - .filter(hasParent(hasTestTag("timeInput"))) - - // Ensure that we have at least two text fields - assert(textFields.fetchSemanticsNodes().size >= 2) - - // Set the hour to "07" - textFields[0].performTextReplacement("10") - - // Set the minute to "00" - textFields[1].performTextReplacement("00") - - // Find the node representing today's date and perform a click - composeTestRule - .onNode(hasText(day.toString()) and hasClickAction() and !hasSetTextAction()) - .performClick() - - composeTestRule.onNodeWithText("OK").performClick() - - composeTestRule.waitForIdle() - - // Verify that 2 workers are displayed - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) - - composeTestRule.onNodeWithText("Availability").performClick() - - composeTestRule.waitForIdle() - - val textFields2 = - composeTestRule.onAllNodes(hasSetTextAction()) - .filter(hasParent(hasTestTag("timeInput"))) - - // Ensure that we have at least two text fields - assert(textFields2.fetchSemanticsNodes().size >= 2) - - // Set the hour to "07" - textFields2[0].performTextReplacement("10") - - // Set the minute to "00" - textFields2[1].performTextReplacement("00") - - // Find the node representing today's date and perform a click - composeTestRule - .onNode( - hasText(LocalDate.now().dayOfMonth.toString()) and - hasClickAction() and - !hasSetTextAction() - ) - .performClick() - - composeTestRule.onNodeWithText("OK").performClick() - - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) + rating = 3.9, + workingHours = Pair(LocalTime.of(0, 0), LocalTime.of(23, 59)), + location = Location(40.0, -74.0))) + + // Provide test data to the searchViewModel + searchViewModel._subCategoryWorkerProfiles.value = workers + + // Set the content + composeTestRule.setContent { + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel, + ) } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - @Test - fun testLocationFilterReselectBehavesCorrectly() { - // Set up test workers with various locations - val workers = - listOf( - WorkerProfile( - uid = "worker1", - location = Location(40.0, -74.0, "Home"), - fieldOfWork = "Painter", - rating = 4.5 - ), - WorkerProfile( - uid = "worker2", - location = Location(45.0, -75.0, "Far"), - fieldOfWork = "Electrician", - rating = 4.0 - ) - ) - - // Provide test data to the searchViewModel - searchViewModel._subCategoryWorkerProfiles.value = workers - - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel - ) - } - - // Initially, both workers should be displayed - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - - composeTestRule.onNodeWithTag("tuneButton").performClick() - // Scroll to the "Location" button in the LazyRow if needed - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - - // Open the Location filter bottom sheet - composeTestRule.onNodeWithText("Location").performClick() - composeTestRule.waitForIdle() - - // Verify bottom sheet is displayed - composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() - - composeTestRule.onNodeWithTag("applyButton").assertIsNotEnabled() - - // Select "Home" location - composeTestRule.onNodeWithTag("locationOptionRow1").performClick() - - // Click Apply - composeTestRule.onNodeWithTag("applyButton").performClick() + composeTestRule.onNodeWithTag("tuneButton").performClick() + composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(5) + // Click on the "Price Range" filter button + composeTestRule.onNodeWithText("Emergency").performClick() - // Verify that only the worker at "Home" is displayed (worker1) - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(1) + // Wait for the UI to update + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - // Open Location filter again to clear - composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(1) - composeTestRule.onNodeWithText("Location").performClick() - composeTestRule.waitForIdle() + val sortedWorkers = listOf(workers[2], workers[1], workers[0]) + val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - // Verify bottom sheet - composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() + workerNodes.assertCountEquals(sortedWorkers.size) - composeTestRule.onNodeWithTag("applyButton").assertIsEnabled() - - composeTestRule.onNodeWithTag("locationOptionRow0").performClick() - - // Clear the filter - composeTestRule.onNodeWithTag("resetButton").performClick() - composeTestRule.waitForIdle() - - // Verify that we are back to the initial state (2 workers displayed) - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(2) - - composeTestRule.onNodeWithText("Location").performClick() - composeTestRule.waitForIdle() - - // Verify bottom sheet - composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() - - composeTestRule.onNodeWithTag("applyButton").assertIsNotEnabled() - composeTestRule.onNodeWithTag("resetButton").assertIsNotEnabled() + sortedWorkers.forEachIndexed { index, worker -> + workerNodes[index].assert( + hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) } - @Test - fun testEmergencyUpdatesResults() { - context = mock(Context::class.java) - activity = mock(Activity::class.java) - fusedLocationProviderClient = mock(FusedLocationProviderClient::class.java) - // Create a spy of LocationHelper - locationHelper = spy(LocationHelper(context, activity, fusedLocationProviderClient)) - - `when`( - ActivityCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION - ) - ) - .thenReturn(PackageManager.PERMISSION_GRANTED) - `when`( - ActivityCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - ) - .thenReturn(PackageManager.PERMISSION_GRANTED) - - // Mock location enabled - val locationManager = mock(LocationManager::class.java) - `when`(context.getSystemService(Context.LOCATION_SERVICE)).thenReturn(locationManager) - `when`(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)).thenReturn(true) - `when`(locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)).thenReturn(true) - - // Mock permissions check - `when`( - ActivityCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION - ) - ) - .thenReturn(PackageManager.PERMISSION_GRANTED) - `when`( - ActivityCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - ) - .thenReturn(PackageManager.PERMISSION_GRANTED) - - // Mock fusedLocationProviderClient.lastLocation - val mockLocation = mock(android.location.Location::class.java) - `when`(mockLocation.latitude).thenReturn(0.0) - `when`(mockLocation.longitude).thenReturn(0.0) - - val mockTask = mock(Task::class.java) as Task - `when`(mockTask.isSuccessful).thenReturn(true) - `when`(mockTask.result).thenReturn(mockLocation) - `when`(fusedLocationProviderClient.lastLocation).thenReturn(mockTask) - - // Mock addOnCompleteListener - `when`(mockTask.addOnCompleteListener(Mockito.any())).thenAnswer { invocation -> - val listener = invocation.arguments[0] as OnCompleteListener - listener.onComplete(mockTask) - mockTask - } - - val workers = - listOf( - WorkerProfile( - uid = "worker1", - price = 150.0, - fieldOfWork = "Painter", - rating = 4.5, - workingHours = Pair(LocalTime.of(0, 0), LocalTime.of(23, 59)), - location = Location(45.0, -75.0) - ), - WorkerProfile( - uid = "worker2", - price = 560.0, - fieldOfWork = "Electrician", - rating = 4.8, - workingHours = Pair(LocalTime.of(0, 0), LocalTime.of(23, 59)), - location = Location(40.7128, -74.0060) - ), - WorkerProfile( - uid = "worker3", - price = 600.0, - fieldOfWork = "Plumber", - rating = 3.9, - workingHours = Pair(LocalTime.of(0, 0), LocalTime.of(23, 59)), - location = Location(40.0, -74.0) - ) - ) - - // Provide test data to the searchViewModel - searchViewModel._subCategoryWorkerProfiles.value = workers - - // Set the content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - preferencesViewModel, - quickFixViewModel, - locationHelper = locationHelper - ) - } - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) + composeTestRule.onNodeWithText("Emergency").performClick() + composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(5) - // Click on the "Price Range" filter button - composeTestRule.onNodeWithText("Emergency").performClick() + val sortedWorkers1 = listOf(workers[0], workers[1], workers[2]) + val workerNodes1 = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - // Wait for the UI to update - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) + workerNodes1.assertCountEquals(sortedWorkers.size) - val sortedWorkers = listOf(workers[2], workers[1], workers[0]) - val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - - workerNodes.assertCountEquals(sortedWorkers.size) - - sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert( - hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true)) - ) - } - - composeTestRule.onNodeWithText("Emergency").performClick() - composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) - - val sortedWorkers1 = listOf(workers[0], workers[1], workers[2]) - val workerNodes1 = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - - workerNodes1.assertCountEquals(sortedWorkers.size) - - sortedWorkers1.forEachIndexed { index, worker -> - workerNodes[index].assert( - hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true)) - ) - } + sortedWorkers1.forEachIndexed { index, worker -> + workerNodes[index].assert( + hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) } + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/AppContentNavGraph.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/AppContentNavGraph.kt index 84560b1b..228bb35d 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/AppContentNavGraph.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/AppContentNavGraph.kt @@ -47,70 +47,68 @@ fun AppContentNavGraph( chatViewModel: ChatViewModel, quickFixViewModel: QuickFixViewModel ) { - val appContentNavController = rememberNavController() - val appContentNavigationActions = remember { NavigationActions(appContentNavController) } - var currentAppMode by remember { mutableStateOf(AppMode.USER) } - Log.d("userContent", "Current App Mode is empty: $currentAppMode") - LaunchedEffect(Unit) { - Log.d("userContent", "Loading App Mode") - currentAppMode = - when (loadAppMode(preferencesViewModel)) { - "USER" -> AppMode.USER - "WORKER" -> AppMode.WORKER - else -> { - AppMode.WORKER - } - } - } - - modeViewModel.switchMode(currentAppMode) - Log.d("MainActivity", "$currentAppMode") - val startDestination = - when (currentAppMode) { - AppMode.USER -> AppContentRoute.USER_MODE - AppMode.WORKER -> AppContentRoute.WORKER_MODE + val appContentNavController = rememberNavController() + val appContentNavigationActions = remember { NavigationActions(appContentNavController) } + var currentAppMode by remember { mutableStateOf(AppMode.USER) } + Log.d("userContent", "Current App Mode is empty: $currentAppMode") + LaunchedEffect(Unit) { + Log.d("userContent", "Loading App Mode") + currentAppMode = + when (loadAppMode(preferencesViewModel)) { + "USER" -> AppMode.USER + "WORKER" -> AppMode.WORKER + else -> { + AppMode.WORKER + } } - NavHost( - navController = appContentNavigationActions.navController, - startDestination = startDestination, // Apply padding from the Scaffold - enterTransition = { - // You can change whatever you want for transitions - EnterTransition.None - }, - exitTransition = { - // You can change whatever you want for transitions - ExitTransition.None - }) { + } + + modeViewModel.switchMode(currentAppMode) + Log.d("MainActivity", "$currentAppMode") + val startDestination = + when (currentAppMode) { + AppMode.USER -> AppContentRoute.USER_MODE + AppMode.WORKER -> AppContentRoute.WORKER_MODE + } + NavHost( + navController = appContentNavigationActions.navController, + startDestination = startDestination, // Apply padding from the Scaffold + enterTransition = { + // You can change whatever you want for transitions + EnterTransition.None + }, + exitTransition = { + // You can change whatever you want for transitions + ExitTransition.None + }) { composable(AppContentRoute.USER_MODE) { - UserModeNavHost( - testBitmapPP, - testLocation, - modeViewModel, - userViewModel, - workerViewModel, - accountViewModel, - categoryViewModel, - locationViewModel, - preferencesViewModel, - rootNavigationActions, - userPreferencesViewModel, - appContentNavigationActions, - chatViewModel, - quickFixViewModel, - isOffline - ) + UserModeNavHost( + testBitmapPP, + testLocation, + modeViewModel, + userViewModel, + workerViewModel, + accountViewModel, + categoryViewModel, + locationViewModel, + preferencesViewModel, + rootNavigationActions, + userPreferencesViewModel, + appContentNavigationActions, + chatViewModel, + quickFixViewModel, + isOffline) } composable(AppContentRoute.WORKER_MODE) { - WorkerModeNavGraph( - modeViewModel, - isOffline, - appContentNavigationActions, - preferencesViewModel, - accountViewModel, - rootNavigationActions, - userPreferencesViewModel - ) + WorkerModeNavGraph( + modeViewModel, + isOffline, + appContentNavigationActions, + preferencesViewModel, + accountViewModel, + rootNavigationActions, + userPreferencesViewModel) } - } + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt index 20c16117..0e8b20fa 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt @@ -49,8 +49,6 @@ import com.arygm.quickfix.ui.elements.QuickFixDisplayImagesScreen import com.arygm.quickfix.ui.elements.QuickFixOfflineBar import com.arygm.quickfix.ui.navigation.BottomNavigationMenu import com.arygm.quickfix.ui.navigation.NavigationActions -import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.QuickFixFinderScreen -import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchWorkerResult import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.camera.QuickFixDisplayImages import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.dashboard.DashboardScreen import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.home.HomeScreen @@ -423,8 +421,9 @@ fun SearchNavHost( searchViewModel, accountViewModel, userViewModel, + quickFixViewModel, preferencesViewModel, - quickFixViewModel) + ) } composable(UserScreen.SEARCH_LOCATION) { LocationSearchCustomScreen( diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt index 0b35dbd2..6cee3062 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/Announcement.kt @@ -118,226 +118,217 @@ fun AnnouncementScreen( initialAvailability: List> = emptyList(), initialUploadedImages: List = emptyList() ) { - var userId by remember { mutableStateOf("") } - - LaunchedEffect(Unit) { userId = loadUserId(preferencesViewModel) } - var title by rememberSaveable { mutableStateOf(initialTitle) } - var subcategoryTitle by rememberSaveable { mutableStateOf(initialSubcategoryTitle) } - var description by rememberSaveable { mutableStateOf(initialDescription) } - - var locationLat by rememberSaveable { mutableStateOf(initialLocation?.latitude) } - var locationLon by rememberSaveable { mutableStateOf(initialLocation?.longitude) } - var locationName by rememberSaveable { mutableStateOf(initialLocation?.name) } - var locationTitle by rememberSaveable { mutableStateOf(initialLocation?.name ?: "") } - var locationIsSelected by rememberSaveable { mutableStateOf(initialLocation != null) } - - var selectedSubcategoryName by rememberSaveable { mutableStateOf("") } - - LaunchedEffect(Unit) { - // Charger les images initiales - initialUploadedImages.forEach { announcementViewModel.addUploadedImage(it) } - } - val location = - if (locationLat != null && locationLon != null && locationName != null) { - Location(latitude = locationLat!!, longitude = locationLon!!, name = locationName!!) - } else null - - var locationExpanded by remember { mutableStateOf(false) } - val locationSuggestions by locationViewModel.locationSuggestions.collectAsState() - - var titleIsEmpty by rememberSaveable { mutableStateOf(true) } - var descriptionIsEmpty by rememberSaveable { mutableStateOf(true) } - - fun LocalDateTime.toMillis(): Long = - this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - - fun millisToLocalDateTime(millis: Long): LocalDateTime = - LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault()) - - val availabilitySaver = - androidx.compose.runtime.saveable.Saver>, List>>( - save = { list -> list.map { pair -> listOf(pair.first, pair.second) } }, - restore = { saved -> saved.mapNotNull { if (it.size == 2) it[0] to it[1] else null } }) - - var isEditingIndex by rememberSaveable { mutableStateOf(null) } - var showStartAvailabilityPopup by remember { mutableStateOf(false) } - var showEndAvailabilityPopup by remember { mutableStateOf(false) } - var tempStartMillis by remember { mutableStateOf(null) } - - val dateFormatter = DateTimeFormatter.ofPattern("EEE, dd MMM") - val timeFormatter = DateTimeFormatter.ofPattern("hh:mm a") - - var subcategoryExpanded by remember { mutableStateOf(false) } - - val uploadedImages by announcementViewModel.uploadedImages.collectAsState() - var showUploadImageSheet by rememberSaveable { mutableStateOf(false) } - - val categories by categoryViewModel.categories.collectAsState() - val allSubcategories = categories.flatMap { it.subcategories } - - val selectedSubcategory = allSubcategories.find { it.name == selectedSubcategoryName } - val categoryIsSelected = selectedSubcategory != null - - LaunchedEffect(Unit) { categoryViewModel.getCategories() } - - val sheetState = rememberModalBottomSheetState() - var listAvailability by - rememberSaveable(stateSaver = availabilitySaver) { mutableStateOf(initialAvailability) } - val resetAnnouncementParameters = { - title = "" - subcategoryTitle = "" - selectedSubcategoryName = "" - description = "" - locationLat = null - locationLon = null - locationName = null - locationTitle = "" - titleIsEmpty = true - descriptionIsEmpty = true - locationIsSelected = false - listAvailability = emptyList() - navigationActions.saveToCurBackStack("selectedLocation", null) - announcementViewModel.clearUploadedImages() + var userId by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { userId = loadUserId(preferencesViewModel) } + var title by rememberSaveable { mutableStateOf(initialTitle) } + var subcategoryTitle by rememberSaveable { mutableStateOf(initialSubcategoryTitle) } + var description by rememberSaveable { mutableStateOf(initialDescription) } + + var locationLat by rememberSaveable { mutableStateOf(initialLocation?.latitude) } + var locationLon by rememberSaveable { mutableStateOf(initialLocation?.longitude) } + var locationName by rememberSaveable { mutableStateOf(initialLocation?.name) } + var locationTitle by rememberSaveable { mutableStateOf(initialLocation?.name ?: "") } + var locationIsSelected by rememberSaveable { mutableStateOf(initialLocation != null) } + + var selectedSubcategoryName by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(Unit) { + // Charger les images initiales + initialUploadedImages.forEach { announcementViewModel.addUploadedImage(it) } + } + val location = + if (locationLat != null && locationLon != null && locationName != null) { + Location(latitude = locationLat!!, longitude = locationLon!!, name = locationName!!) + } else null + + var locationExpanded by remember { mutableStateOf(false) } + val locationSuggestions by locationViewModel.locationSuggestions.collectAsState() + + var titleIsEmpty by rememberSaveable { mutableStateOf(true) } + var descriptionIsEmpty by rememberSaveable { mutableStateOf(true) } + + fun LocalDateTime.toMillis(): Long = + this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + fun millisToLocalDateTime(millis: Long): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault()) + + val availabilitySaver = + androidx.compose.runtime.saveable.Saver>, List>>( + save = { list -> list.map { pair -> listOf(pair.first, pair.second) } }, + restore = { saved -> saved.mapNotNull { if (it.size == 2) it[0] to it[1] else null } }) + + var isEditingIndex by rememberSaveable { mutableStateOf(null) } + var showStartAvailabilityPopup by remember { mutableStateOf(false) } + var showEndAvailabilityPopup by remember { mutableStateOf(false) } + var tempStartMillis by remember { mutableStateOf(null) } + + val dateFormatter = DateTimeFormatter.ofPattern("EEE, dd MMM") + val timeFormatter = DateTimeFormatter.ofPattern("hh:mm a") + + var subcategoryExpanded by remember { mutableStateOf(false) } + + val uploadedImages by announcementViewModel.uploadedImages.collectAsState() + var showUploadImageSheet by rememberSaveable { mutableStateOf(false) } + + val categories by categoryViewModel.categories.collectAsState() + val allSubcategories = categories.flatMap { it.subcategories } + + val selectedSubcategory = allSubcategories.find { it.name == selectedSubcategoryName } + val categoryIsSelected = selectedSubcategory != null + + LaunchedEffect(Unit) { categoryViewModel.getCategories() } + + val sheetState = rememberModalBottomSheetState() + var listAvailability by + rememberSaveable(stateSaver = availabilitySaver) { mutableStateOf(initialAvailability) } + val resetAnnouncementParameters = { + title = "" + subcategoryTitle = "" + selectedSubcategoryName = "" + description = "" + locationLat = null + locationLon = null + locationName = null + locationTitle = "" + titleIsEmpty = true + descriptionIsEmpty = true + locationIsSelected = false + listAvailability = emptyList() + navigationActions.saveToCurBackStack("selectedLocation", null) + announcementViewModel.clearUploadedImages() + } + + // Function to update user profile + val updateUserProfileWithAnnouncement: (Announcement) -> Unit = { announcement -> + profileViewModel.fetchUserProfile(userId) { profile -> + if (profile is UserProfile) { + val announcementList = profile.announcements + announcement.announcementId + + profileViewModel.updateProfile( + UserProfile(profile.locations, announcementList, profile.wallet, profile.uid), + onSuccess = { + accountViewModel.fetchUserAccount(profile.uid) { account -> + if (account != null) { + setAccountPreferences(preferencesViewModel, account) + } + } + }, + onFailure = { e -> + Log.e("ProfileViewModel", "Failed to update profile: ${e.message}") + }) + } else { + Log.e("Wrong profile", "Should be a user profile") + } } - - // Function to update user profile - val updateUserProfileWithAnnouncement: (Announcement) -> Unit = { announcement -> - profileViewModel.fetchUserProfile(userId) { profile -> - if (profile is UserProfile) { - val announcementList = profile.announcements + announcement.announcementId - - profileViewModel.updateProfile( - UserProfile(profile.locations, announcementList, profile.wallet, profile.uid), - onSuccess = { - accountViewModel.fetchUserAccount(profile.uid) { account -> - if (account != null) { - setAccountPreferences(preferencesViewModel, account) - } - } - }, - onFailure = { e -> - Log.e("ProfileViewModel", "Failed to update profile: ${e.message}") - }) - } else { - Log.e("Wrong profile", "Should be a user profile") + } + + // Function to handle successful image upload + val handleSuccessfulImageUpload: (String, List) -> Unit = + { announcementId, uploadedImageUrls -> + val availabilitySlots = + listAvailability.map { (startMillis, endMillis) -> + val start = millisToTimestamp(startMillis) + val end = millisToTimestamp(endMillis) + AvailabilitySlot(start = start, end = end) } - } - } - // Function to handle successful image upload - val handleSuccessfulImageUpload: (String, List) -> Unit = - { announcementId, uploadedImageUrls -> - val availabilitySlots = - listAvailability.map { (startMillis, endMillis) -> - val start = millisToTimestamp(startMillis) - val end = millisToTimestamp(endMillis) - AvailabilitySlot(start = start, end = end) - } - - val announcement = - Announcement( - announcementId = announcementId, - userId = userId, - title = title, - category = selectedSubcategory?.name ?: "", - description = description, - location = location, - availability = availabilitySlots, - quickFixImages = uploadedImageUrls - ) - announcementViewModel.announce(announcement) - - // Clear the added pictures - announcementViewModel.clearUploadedImages() - - // Update the user profile with the new announcement - updateUserProfileWithAnnouncement(announcement) - - // Reset all parameters after making an announcement - resetAnnouncementParameters() - } + val announcement = + Announcement( + announcementId = announcementId, + userId = userId, + title = title, + category = selectedSubcategory?.name ?: "", + description = description, + location = location, + availability = availabilitySlots, + quickFixImages = uploadedImageUrls) + announcementViewModel.announce(announcement) + + // Clear the added pictures + announcementViewModel.clearUploadedImages() - if (showStartAvailabilityPopup) { - Dialog(onDismissRequest = { showStartAvailabilityPopup = false }) { - println("showStartAvailabilityPopup") - QuickFixDateTimePicker( - onDateTimeSelected = { date, time -> - val start = LocalDateTime.of(date, time) - tempStartMillis = start.toMillis() - showStartAvailabilityPopup = false - showEndAvailabilityPopup = true - }, - onDismissRequest = { showStartAvailabilityPopup = false }, - modifier = Modifier.testTag("startAvailabilityPicker") - ) - } + // Update the user profile with the new announcement + updateUserProfileWithAnnouncement(announcement) + + // Reset all parameters after making an announcement + resetAnnouncementParameters() + } + + if (showStartAvailabilityPopup) { + Dialog(onDismissRequest = { showStartAvailabilityPopup = false }) { + println("showStartAvailabilityPopup") + QuickFixDateTimePicker( + onDateTimeSelected = { date, time -> + val start = LocalDateTime.of(date, time) + tempStartMillis = start.toMillis() + showStartAvailabilityPopup = false + showEndAvailabilityPopup = true + }, + onDismissRequest = { showStartAvailabilityPopup = false }, + modifier = Modifier.testTag("startAvailabilityPicker")) } - - if (showEndAvailabilityPopup) { - Dialog(onDismissRequest = { showEndAvailabilityPopup = false }) { - QuickFixDateTimePicker( - onDateTimeSelected = { date, time -> - val end = LocalDateTime.of(date, time) - tempStartMillis?.let { startMillis -> - if (isEditingIndex == null) { - listAvailability = listAvailability + (startMillis to end.toMillis()) - } else { - val mutable = listAvailability.toMutableList() - mutable[isEditingIndex!!] = (startMillis to end.toMillis()) - listAvailability = mutable - isEditingIndex = null - } - } - tempStartMillis = null - showEndAvailabilityPopup = false - }, - onDismissRequest = { showEndAvailabilityPopup = false }, - modifier = Modifier.testTag("endAvailabilityPicker") - ) - } + } + + if (showEndAvailabilityPopup) { + Dialog(onDismissRequest = { showEndAvailabilityPopup = false }) { + QuickFixDateTimePicker( + onDateTimeSelected = { date, time -> + val end = LocalDateTime.of(date, time) + tempStartMillis?.let { startMillis -> + if (isEditingIndex == null) { + listAvailability = listAvailability + (startMillis to end.toMillis()) + } else { + val mutable = listAvailability.toMutableList() + mutable[isEditingIndex!!] = (startMillis to end.toMillis()) + listAvailability = mutable + isEditingIndex = null + } + } + tempStartMillis = null + showEndAvailabilityPopup = false + }, + onDismissRequest = { showEndAvailabilityPopup = false }, + modifier = Modifier.testTag("endAvailabilityPicker")) } - - BoxWithConstraints { - val widthRatio = maxWidth / 411 - val heightRatio = maxHeight / 860 - - val categoryTextStyle = - MaterialTheme.typography.labelMedium.copy( - fontSize = 10.sp, color = colorScheme.onBackground, fontWeight = FontWeight.Medium - ) - - val maxCategoryTextWidth = - calculateMaxTextWidth( - texts = allSubcategories.map { it.name }, textStyle = categoryTextStyle - ) - - val dropdownMenuWidth = maxCategoryTextWidth + 40.dp - - Scaffold( - containerColor = colorScheme.surface, - topBar = {}, - modifier = Modifier.testTag("AnnouncementContent") - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .padding( - start = 14.dp * widthRatio.value, - end = 14.dp * widthRatio.value, - top = 30.dp * heightRatio.value - ) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top - ) { + } + + BoxWithConstraints { + val widthRatio = maxWidth / 411 + val heightRatio = maxHeight / 860 + + val categoryTextStyle = + MaterialTheme.typography.labelMedium.copy( + fontSize = 10.sp, color = colorScheme.onBackground, fontWeight = FontWeight.Medium) + + val maxCategoryTextWidth = + calculateMaxTextWidth( + texts = allSubcategories.map { it.name }, textStyle = categoryTextStyle) + + val dropdownMenuWidth = maxCategoryTextWidth + 40.dp + + Scaffold( + containerColor = colorScheme.surface, + topBar = {}, + modifier = Modifier.testTag("AnnouncementContent")) { padding -> + Column( + modifier = + Modifier.fillMaxSize() + .padding(padding) + .padding( + start = 14.dp * widthRatio.value, + end = 14.dp * widthRatio.value, + top = 30.dp * heightRatio.value) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top) { // Title QuickFixTextFieldCustom( value = title, onValueChange = { - title = it - titleIsEmpty = title.isEmpty() + title = it + titleIsEmpty = title.isEmpty() }, placeHolderText = "Enter the title of your quickFix", placeHolderColor = colorScheme.onSecondaryContainer, @@ -347,97 +338,87 @@ fun AnnouncementScreen( widthField = 380.dp * widthRatio.value, showLabel = true, label = { - Text( - text = - buildAnnotatedString { + Text( + text = + buildAnnotatedString { append("Title") withStyle(style = SpanStyle(color = colorScheme.primary)) { - append(" *") + append(" *") } - }, - style = - MaterialTheme.typography.headlineMedium.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), - color = colorScheme.onBackground, - modifier = Modifier.testTag("titleText") - ) + }, + style = + MaterialTheme.typography.headlineMedium.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = colorScheme.onBackground, + modifier = Modifier.testTag("titleText")) }, hasShadow = false, borderColor = colorScheme.tertiaryContainer, - modifier = Modifier.testTag("titleInput") - ) + modifier = Modifier.testTag("titleInput")) Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) // Subcategory Text( text = - buildAnnotatedString { - append("Subcategory") - withStyle(style = SpanStyle(color = colorScheme.primary)) { append(" *") } - }, + buildAnnotatedString { + append("Subcategory") + withStyle(style = SpanStyle(color = colorScheme.primary)) { append(" *") } + }, style = - MaterialTheme.typography.headlineMedium.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), + MaterialTheme.typography.headlineMedium.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), color = colorScheme.onBackground, - modifier = Modifier.testTag("categoryText") - ) + modifier = Modifier.testTag("categoryText")) Box { - QuickFixTextFieldCustom( - value = subcategoryTitle, - onValueChange = { newValue -> - subcategoryTitle = newValue - subcategoryExpanded = - newValue.isNotEmpty() && allSubcategories.isNotEmpty() - }, - placeHolderText = "Select a subcategory", - placeHolderColor = colorScheme.onSecondaryContainer, - shape = RoundedCornerShape(8.dp), - moveContentHorizontal = 10.dp, - heightField = 40.dp * heightRatio.value, - widthField = 380.dp * widthRatio.value, - showLabel = false, - hasShadow = false, - borderColor = colorScheme.tertiaryContainer, - modifier = - Modifier.testTag("categoryInput") // This will serve as category input - ) - - DropdownMenu( - expanded = subcategoryExpanded, - properties = PopupProperties(focusable = false), - onDismissRequest = { subcategoryExpanded = false }, - modifier = Modifier.width(dropdownMenuWidth * widthRatio.value), - containerColor = colorScheme.surface - ) { + QuickFixTextFieldCustom( + value = subcategoryTitle, + onValueChange = { newValue -> + subcategoryTitle = newValue + subcategoryExpanded = newValue.isNotEmpty() && allSubcategories.isNotEmpty() + }, + placeHolderText = "Select a subcategory", + placeHolderColor = colorScheme.onSecondaryContainer, + shape = RoundedCornerShape(8.dp), + moveContentHorizontal = 10.dp, + heightField = 40.dp * heightRatio.value, + widthField = 380.dp * widthRatio.value, + showLabel = false, + hasShadow = false, + borderColor = colorScheme.tertiaryContainer, + modifier = + Modifier.testTag("categoryInput") // This will serve as category input + ) + + DropdownMenu( + expanded = subcategoryExpanded, + properties = PopupProperties(focusable = false), + onDismissRequest = { subcategoryExpanded = false }, + modifier = Modifier.width(dropdownMenuWidth * widthRatio.value), + containerColor = colorScheme.surface) { val filteredSubcategories = allSubcategories.filter { - it.name.contains(subcategoryTitle, ignoreCase = true) + it.name.contains(subcategoryTitle, ignoreCase = true) } filteredSubcategories.forEachIndexed { index, sub -> - DropdownMenuItem( - text = { Text(text = sub.name, style = categoryTextStyle) }, - onClick = { - subcategoryExpanded = false - subcategoryTitle = sub.name - selectedSubcategoryName = sub.name - }, - modifier = - Modifier - .height(30.dp * heightRatio.value) - .testTag("subcategoryItem$index") - ) - if (index < filteredSubcategories.size - 1) { - HorizontalDivider( - color = colorScheme.onSecondaryContainer, thickness = 1.5.dp - ) - } + DropdownMenuItem( + text = { Text(text = sub.name, style = categoryTextStyle) }, + onClick = { + subcategoryExpanded = false + subcategoryTitle = sub.name + selectedSubcategoryName = sub.name + }, + modifier = + Modifier.height(30.dp * heightRatio.value) + .testTag("subcategoryItem$index")) + if (index < filteredSubcategories.size - 1) { + HorizontalDivider( + color = colorScheme.onSecondaryContainer, thickness = 1.5.dp) + } } - } + } } Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) @@ -446,8 +427,8 @@ fun AnnouncementScreen( QuickFixTextFieldCustom( value = description, onValueChange = { - description = it - descriptionIsEmpty = description.isEmpty() + description = it + descriptionIsEmpty = description.isEmpty() }, placeHolderText = "Describe the quickFix", placeHolderColor = colorScheme.onSecondaryContainer, @@ -457,21 +438,19 @@ fun AnnouncementScreen( widthField = 380.dp * widthRatio.value, showLabel = true, label = { - Text( - text = - buildAnnotatedString { + Text( + text = + buildAnnotatedString { append("Description") withStyle(style = SpanStyle(color = colorScheme.primary)) { - append(" *") + append(" *") } - }, - style = - MaterialTheme.typography.headlineMedium.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), - color = colorScheme.onBackground, - modifier = Modifier.testTag("descriptionText") - ) + }, + style = + MaterialTheme.typography.headlineMedium.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = colorScheme.onBackground, + modifier = Modifier.testTag("descriptionText")) }, hasShadow = false, borderColor = colorScheme.tertiaryContainer, @@ -480,38 +459,34 @@ fun AnnouncementScreen( showCharCounter = true, moveCounter = 17.dp, charCounterTextStyle = - MaterialTheme.typography.bodySmall.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), + MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), charCounterColor = colorScheme.onSecondaryContainer, - modifier = Modifier.testTag("descriptionInput") - ) + modifier = Modifier.testTag("descriptionInput")) Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) // Location Text( text = - buildAnnotatedString { - append("Location") - withStyle(style = SpanStyle(color = colorScheme.primary)) { append(" *") } - }, + buildAnnotatedString { + append("Location") + withStyle(style = SpanStyle(color = colorScheme.primary)) { append(" *") } + }, style = - MaterialTheme.typography.headlineMedium.copy( - fontSize = 12.sp, fontWeight = FontWeight.Medium - ), + MaterialTheme.typography.headlineMedium.copy( + fontSize = 12.sp, fontWeight = FontWeight.Medium), color = colorScheme.onBackground, - modifier = Modifier.testTag("locationText") - ) + modifier = Modifier.testTag("locationText")) QuickFixTextFieldCustom( value = locationTitle, onValueChange = { - locationExpanded = it.isNotEmpty() && locationSuggestions.isNotEmpty() - locationTitle = it - if (it.isNotEmpty()) { - locationViewModel.setQuery(it) - } + locationExpanded = it.isNotEmpty() && locationSuggestions.isNotEmpty() + locationTitle = it + if (it.isNotEmpty()) { + locationViewModel.setQuery(it) + } }, singleLine = true, placeHolderText = "Location", @@ -526,8 +501,7 @@ fun AnnouncementScreen( borderColor = colorScheme.tertiaryContainer, borderThickness = 1.5.dp, textStyle = poppinsTypography.labelSmall.copy(fontWeight = FontWeight.Medium), - modifier = Modifier.testTag("locationInput") - ) + modifier = Modifier.testTag("locationInput")) DropdownMenu( expanded = locationExpanded, @@ -536,59 +510,54 @@ fun AnnouncementScreen( modifier = Modifier.width(380.dp * widthRatio.value), containerColor = colorScheme.surface, ) { - locationSuggestions.forEachIndexed { index, suggestion -> - DropdownMenuItem( - onClick = { - locationExpanded = false - locationViewModel.setQuery(suggestion.name) - locationTitle = suggestion.name - locationLat = suggestion.latitude - locationLon = suggestion.longitude - locationName = suggestion.name - locationIsSelected = true - }, - text = { - Text( - text = suggestion.name, - style = poppinsTypography.labelSmall, - fontWeight = FontWeight.Medium, - color = colorScheme.onBackground, - modifier = Modifier.padding(horizontal = 4.dp) - ) - }, - modifier = Modifier.testTag("locationSuggestionItem") - ) - if (index < locationSuggestions.size - 1) { - HorizontalDivider( - color = colorScheme.onSecondaryContainer, thickness = 1.5.dp - ) - } + locationSuggestions.forEachIndexed { index, suggestion -> + DropdownMenuItem( + onClick = { + locationExpanded = false + locationViewModel.setQuery(suggestion.name) + locationTitle = suggestion.name + locationLat = suggestion.latitude + locationLon = suggestion.longitude + locationName = suggestion.name + locationIsSelected = true + }, + text = { + Text( + text = suggestion.name, + style = poppinsTypography.labelSmall, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 4.dp)) + }, + modifier = Modifier.testTag("locationSuggestionItem")) + if (index < locationSuggestions.size - 1) { + HorizontalDivider( + color = colorScheme.onSecondaryContainer, thickness = 1.5.dp) } + } } Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) // Availability val startAvailability = { - isEditingIndex = null - showStartAvailabilityPopup = true + isEditingIndex = null + showStartAvailabilityPopup = true } if (listAvailability.isEmpty()) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth()) { Button( onClick = startAvailability, modifier = Modifier.padding(vertical = 16.dp * heightRatio.value), shape = RoundedCornerShape(10.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier.wrapContentWidth() - ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + modifier = Modifier.wrapContentWidth()) { Icon( painter = painterResource(R.drawable.calendar), contentDescription = "Calendar", @@ -599,169 +568,143 @@ fun AnnouncementScreen( text = "Add Availability", style = poppinsTypography.labelSmall, color = colorScheme.onPrimary, - fontWeight = FontWeight.Bold - ) - } + fontWeight = FontWeight.Bold) + } } - } + } } else { - Row( - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.Top, - modifier = Modifier.fillMaxWidth() - ) { + Row( + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth()) { Column( modifier = - Modifier - .fillMaxWidth() - .padding(top = 16.dp * heightRatio.value, start = 4.dp) - .weight(0.8f), - horizontalAlignment = Alignment.Start - ) { - Text( - text = "Availability", - style = poppinsTypography.headlineMedium, - color = colorScheme.onBackground, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - modifier = Modifier.padding(bottom = 16.dp * heightRatio.value) - ) - Row( - modifier = Modifier - .fillMaxWidth(0.8f) - .padding(bottom = 4.dp), - ) { + Modifier.fillMaxWidth() + .padding(top = 16.dp * heightRatio.value, start = 4.dp) + .weight(0.8f), + horizontalAlignment = Alignment.Start) { + Text( + text = "Availability", + style = poppinsTypography.headlineMedium, + color = colorScheme.onBackground, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + modifier = Modifier.padding(bottom = 16.dp * heightRatio.value)) + Row( + modifier = Modifier.fillMaxWidth(0.8f).padding(bottom = 4.dp), + ) { Text( text = "Day", style = poppinsTypography.labelSmall, color = colorScheme.onBackground, fontWeight = FontWeight.Medium, - modifier = Modifier.weight(0.42f) - ) + modifier = Modifier.weight(0.42f)) Text( text = "Time", style = poppinsTypography.labelSmall, color = colorScheme.onBackground, fontWeight = FontWeight.Medium, - modifier = Modifier.weight(0.38f) - ) + modifier = Modifier.weight(0.38f)) + } } - } IconButton( onClick = startAvailability, modifier = - Modifier - .testTag("Add Availability Button") - .padding(top = 16.dp * heightRatio.value, end = 4.dp) - .weight(0.2f), + Modifier.testTag("Add Availability Button") + .padding(top = 16.dp * heightRatio.value, end = 4.dp) + .weight(0.2f), content = { - Icon( - imageVector = Icons.Default.Add, - contentDescription = "Add Availability", - ) + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add Availability", + ) }, colors = - IconButtonDefaults.iconButtonColors( - contentColor = colorScheme.primary, - ) - ) - } - - listAvailability.forEachIndexed { index, (startMillis, endMillis) -> - HorizontalDivider( - color = colorScheme.background, - thickness = 1.5.dp, - modifier = Modifier - .fillMaxWidth(0.5f) - .padding(start = 4.dp) - ) - - val start = millisToLocalDateTime(startMillis) - val end = millisToLocalDateTime(endMillis) - - val startDay = start.toLocalDate().format(dateFormatter) - val endDay = end.toLocalDate().format(dateFormatter) - val startTimeText = start.toLocalTime().format(timeFormatter) - val endTimeText = end.toLocalTime().format(timeFormatter) - - val dayText = - if (startDay == endDay) { - startDay - } else { - "$startDay - $endDay" - } + IconButtonDefaults.iconButtonColors( + contentColor = colorScheme.primary, + )) + } + + listAvailability.forEachIndexed { index, (startMillis, endMillis) -> + HorizontalDivider( + color = colorScheme.background, + thickness = 1.5.dp, + modifier = Modifier.fillMaxWidth(0.5f).padding(start = 4.dp)) + + val start = millisToLocalDateTime(startMillis) + val end = millisToLocalDateTime(endMillis) + + val startDay = start.toLocalDate().format(dateFormatter) + val endDay = end.toLocalDate().format(dateFormatter) + val startTimeText = start.toLocalTime().format(timeFormatter) + val endTimeText = end.toLocalTime().format(timeFormatter) + + val dayText = + if (startDay == endDay) { + startDay + } else { + "$startDay - $endDay" + } - val timeText = "$startTimeText - $endTimeText" + val timeText = "$startTimeText - $endTimeText" - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .fillMaxWidth() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier.fillMaxWidth() .padding(horizontal = 4.dp) - .testTag("availabilitySlot") - ) { - Text( - text = dayText, - style = poppinsTypography.labelSmall, - color = colorScheme.onBackground, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(0.335f) - ) - Text( - text = timeText, - style = poppinsTypography.labelSmall, - color = colorScheme.onBackground, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(0.35f) - ) - TextButton( - onClick = { - isEditingIndex = index - tempStartMillis = startMillis - showStartAvailabilityPopup = true - }, - modifier = Modifier - .wrapContentWidth() - .weight(0.15f), - shape = RoundedCornerShape(10.dp), - colors = - ButtonDefaults.textButtonColors( - contentColor = colorScheme.primary, - ), - contentPadding = PaddingValues(0.dp) - ) { + .testTag("availabilitySlot")) { + Text( + text = dayText, + style = poppinsTypography.labelSmall, + color = colorScheme.onBackground, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(0.335f)) + Text( + text = timeText, + style = poppinsTypography.labelSmall, + color = colorScheme.onBackground, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(0.35f)) + TextButton( + onClick = { + isEditingIndex = index + tempStartMillis = startMillis + showStartAvailabilityPopup = true + }, + modifier = Modifier.wrapContentWidth().weight(0.15f), + shape = RoundedCornerShape(10.dp), + colors = + ButtonDefaults.textButtonColors( + contentColor = colorScheme.primary, + ), + contentPadding = PaddingValues(0.dp)) { Text( text = "Edit", style = poppinsTypography.labelSmall, - fontWeight = FontWeight.SemiBold - ) - } - TextButton( - onClick = { - listAvailability = - listAvailability.toMutableList().apply { removeAt(index) } - }, - modifier = Modifier - .wrapContentWidth() - .weight(0.15f), - shape = RoundedCornerShape(10.dp), - colors = - ButtonDefaults.textButtonColors( - contentColor = colorScheme.primary, - ), - contentPadding = PaddingValues(0.dp) - ) { + fontWeight = FontWeight.SemiBold) + } + TextButton( + onClick = { + listAvailability = + listAvailability.toMutableList().apply { removeAt(index) } + }, + modifier = Modifier.wrapContentWidth().weight(0.15f), + shape = RoundedCornerShape(10.dp), + colors = + ButtonDefaults.textButtonColors( + contentColor = colorScheme.primary, + ), + contentPadding = PaddingValues(0.dp)) { Text( text = "Remove", style = poppinsTypography.labelSmall, - fontWeight = FontWeight.SemiBold - ) - } + fontWeight = FontWeight.SemiBold) + } } - } + } } Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) @@ -773,34 +716,27 @@ fun AnnouncementScreen( color = colorScheme.onBackground, fontWeight = FontWeight.Medium, modifier = - Modifier.padding( - start = 4.dp, bottom = 8.dp, top = 16.dp * heightRatio.value - ) - ) + Modifier.padding( + start = 4.dp, bottom = 8.dp, top = 16.dp * heightRatio.value)) if (uploadedImages.isEmpty()) { - Column( - modifier = - Modifier - .fillMaxWidth() - .height(100.dp * heightRatio.value) - .testTag( - "picturesButton" - ) // Tag for the upload pictures button scenario - .dashedBorder( - width = 1.5.dp, - brush = SolidColor(colorScheme.onSecondaryContainer), - shape = RoundedCornerShape(10.dp), - on = 7.dp, - off = 7.dp - ) - .background( - color = colorScheme.background, - shape = RoundedCornerShape(10.dp) - ), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column( + modifier = + Modifier.fillMaxWidth() + .height(100.dp * heightRatio.value) + .testTag( + "picturesButton") // Tag for the upload pictures button scenario + .dashedBorder( + width = 1.5.dp, + brush = SolidColor(colorScheme.onSecondaryContainer), + shape = RoundedCornerShape(10.dp), + on = 7.dp, + off = 7.dp) + .background( + color = colorScheme.background, + shape = RoundedCornerShape(10.dp)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { QuickFixButton( buttonText = "Upload Pictures", buttonColor = colorScheme.background, @@ -809,111 +745,92 @@ fun AnnouncementScreen( height = 50.dp, textColor = colorScheme.onBackground, textStyle = - poppinsTypography.labelSmall.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - ), + poppinsTypography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ), leadingIcon = Icons.Default.PhotoLibrary, - contentPadding = PaddingValues(0.dp) - ) - } + contentPadding = PaddingValues(0.dp)) + } } else { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(100.dp * heightRatio.value) - .padding(horizontal = 16.dp) - .testTag("uploadedImagesBox"), // Tag the uploaded images box - contentAlignment = Alignment.Center - ) { + Box( + modifier = + Modifier.fillMaxWidth() + .height(100.dp * heightRatio.value) + .padding(horizontal = 16.dp) + .testTag("uploadedImagesBox"), // Tag the uploaded images box + contentAlignment = Alignment.Center) { LazyRow( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = - Modifier - .fillMaxWidth() - .height(100.dp * heightRatio.value) - .testTag("uploadedImagesLazyRow") - ) { - val visibleImages = uploadedImages.take(3) - val remainingImageCount = uploadedImages.size - 3 + Modifier.fillMaxWidth() + .height(100.dp * heightRatio.value) + .testTag("uploadedImagesLazyRow")) { + val visibleImages = uploadedImages.take(3) + val remainingImageCount = uploadedImages.size - 3 - items(visibleImages.size) { index -> + items(visibleImages.size) { index -> Box( modifier = - Modifier - .padding(4.dp) - .size(90.dp) - .clip(RoundedCornerShape(8.dp)) - .testTag("uploadedImageCard$index") - ) { - Image( - painter = rememberAsyncImagePainter(visibleImages[index]), - contentDescription = "Image $index", - modifier = - Modifier - .fillMaxSize() - .testTag("uploadedImage$index"), - contentScale = ContentScale.Crop - ) - - if (index == 2 && remainingImageCount > 0) { + Modifier.padding(4.dp) + .size(90.dp) + .clip(RoundedCornerShape(8.dp)) + .testTag("uploadedImageCard$index")) { + Image( + painter = rememberAsyncImagePainter(visibleImages[index]), + contentDescription = "Image $index", + modifier = + Modifier.fillMaxSize().testTag("uploadedImage$index"), + contentScale = ContentScale.Crop) + + if (index == 2 && remainingImageCount > 0) { Box( modifier = - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.6f)) - .clickable { - navigationActions.navigateTo( - UserScreen.DISPLAY_UPLOADED_IMAGES - ) - } - .testTag("remainingImagesOverlay"), + Modifier.fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable { + navigationActions.navigateTo( + UserScreen.DISPLAY_UPLOADED_IMAGES) + } + .testTag("remainingImagesOverlay"), contentAlignment = Alignment.Center) { - Text( - text = "+$remainingImageCount", - color = Color.White, - style = MaterialTheme.typography.bodyLarge - ) - } - } - - IconButton( - onClick = { + Text( + text = "+$remainingImageCount", + color = Color.White, + style = MaterialTheme.typography.bodyLarge) + } + } + + IconButton( + onClick = { announcementViewModel.deleteUploadedImages( - listOf(visibleImages[index]) - ) - }, - modifier = - Modifier - .align(Alignment.TopEnd) - .padding(4.dp) - .size(24.dp) - .clip(CircleShape) - .testTag("deleteImageButton$index") - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Remove Image", - tint = Color.White, - modifier = - Modifier.background( - color = Color.Black.copy(alpha = 0.6f), - shape = CircleShape - ) - ) + listOf(visibleImages[index])) + }, + modifier = + Modifier.align(Alignment.TopEnd) + .padding(4.dp) + .size(24.dp) + .clip(CircleShape) + .testTag("deleteImageButton$index")) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Remove Image", + tint = Color.White, + modifier = + Modifier.background( + color = Color.Black.copy(alpha = 0.6f), + shape = CircleShape)) + } } - } + } } - } - } + } - Spacer(modifier = Modifier.height(8.dp)) - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth()) { QuickFixButton( buttonText = "Add more pictures", buttonColor = colorScheme.primary, @@ -921,18 +838,15 @@ fun AnnouncementScreen( height = 50.dp * heightRatio.value, textColor = colorScheme.onPrimary, textStyle = - poppinsTypography.labelSmall.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - ), - modifier = Modifier - .wrapContentWidth() - .padding(horizontal = 16.dp), + poppinsTypography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ), + modifier = Modifier.wrapContentWidth().padding(horizontal = 16.dp), leadingIcon = Icons.Default.PhotoLibrary, leadingIconTint = colorScheme.onPrimary, - contentPadding = PaddingValues(0.dp) - ) - } + contentPadding = PaddingValues(0.dp)) + } } Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) @@ -941,19 +855,16 @@ fun AnnouncementScreen( Text( text = "* Mandatory fields", color = - if (titleIsEmpty || - !categoryIsSelected || - !locationIsSelected || - descriptionIsEmpty - ) - colorScheme.error - else colorScheme.onSecondaryContainer, + if (titleIsEmpty || + !categoryIsSelected || + !locationIsSelected || + descriptionIsEmpty) + colorScheme.error + else colorScheme.onSecondaryContainer, style = - MaterialTheme.typography.bodySmall.copy( - fontSize = 10.sp, fontWeight = FontWeight.Medium - ), - modifier = Modifier.testTag("mandatoryText") - ) + MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, fontWeight = FontWeight.Medium), + modifier = Modifier.testTag("mandatoryText")) Spacer(modifier = Modifier.height(17.dp * heightRatio.value)) @@ -961,56 +872,51 @@ fun AnnouncementScreen( QuickFixButton( buttonText = "Post your announcement", onClickAction = { - val announcementId = announcementViewModel.getNewUid() - val images = announcementViewModel.uploadedImages.value - - if (images.isEmpty()) { - handleSuccessfulImageUpload(announcementId, emptyList()) - } else { - announcementViewModel.uploadAnnouncementImages( - announcementId = announcementId, - images = images, - onSuccess = { uploadedImageUrls -> - handleSuccessfulImageUpload(announcementId, uploadedImageUrls) - }, - onFailure = { e -> - Log.e( - "AnnouncementViewModel", - "Failed to upload images: ${e.message}" - ) - }) - } + val announcementId = announcementViewModel.getNewUid() + val images = announcementViewModel.uploadedImages.value + + if (images.isEmpty()) { + handleSuccessfulImageUpload(announcementId, emptyList()) + } else { + announcementViewModel.uploadAnnouncementImages( + announcementId = announcementId, + images = images, + onSuccess = { uploadedImageUrls -> + handleSuccessfulImageUpload(announcementId, uploadedImageUrls) + }, + onFailure = { e -> + Log.e( + "AnnouncementViewModel", "Failed to upload images: ${e.message}") + }) + } }, buttonColor = colorScheme.primary, textColor = colorScheme.onPrimary, textStyle = - MaterialTheme.typography.titleMedium.copy( - fontSize = 16.sp, fontWeight = FontWeight.SemiBold - ), + MaterialTheme.typography.titleMedium.copy( + fontSize = 16.sp, fontWeight = FontWeight.SemiBold), modifier = - Modifier - .width(380.dp * widthRatio.value) - .height(50.dp * heightRatio.value) - .testTag("announcementButton"), + Modifier.width(380.dp * widthRatio.value) + .height(50.dp * heightRatio.value) + .testTag("announcementButton"), enabled = - !titleIsEmpty && + !titleIsEmpty && categoryIsSelected && locationIsSelected && - !descriptionIsEmpty - ) + !descriptionIsEmpty) Spacer(modifier = Modifier.height(80.dp * heightRatio.value)) - } + } } - QuickFixUploadImageSheet( - sheetState = sheetState, - showModalBottomSheet = showUploadImageSheet, - onDismissRequest = { showUploadImageSheet = false }, - onShowBottomSheetChange = { showUploadImageSheet = it }, - onActionRequest = { value -> announcementViewModel.addUploadedImage(value) }) - } + QuickFixUploadImageSheet( + sheetState = sheetState, + showModalBottomSheet = showUploadImageSheet, + onDismissRequest = { showUploadImageSheet = false }, + onShowBottomSheetChange = { showUploadImageSheet = it }, + onActionRequest = { value -> announcementViewModel.addUploadedImage(value) }) + } } fun millisToTimestamp(millis: Long): Timestamp { - return Timestamp(millis / 1000, ((millis % 1000) * 1000000).toInt()) + return Timestamp(millis / 1000, ((millis % 1000) * 1000000).toInt()) } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt index 1e1469e5..26117627 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt @@ -1,6 +1,5 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -11,94 +10,92 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.testTag -import com.arygm.quickfix.MainActivity import com.arygm.quickfix.R import com.arygm.quickfix.model.account.Account import com.arygm.quickfix.model.account.AccountViewModel +import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.utils.GeocoderWrapper -import com.arygm.quickfix.utils.LocationHelper import kotlin.math.roundToInt @Composable fun ProfileResults( modifier: Modifier = Modifier, profiles: List, - listState: LazyListState, searchViewModel: SearchViewModel, + listState: LazyListState, accountViewModel: AccountViewModel, geocoderWrapper: GeocoderWrapper = GeocoderWrapper(LocalContext.current), - onBookClick: (WorkerProfile, String) -> Unit + onBookClick: (WorkerProfile) -> Unit, + baseLocation: Location ) { - fun getCityNameFromCoordinates(latitude: Double, longitude: Double): String? { - val addresses = geocoderWrapper.getFromLocation(latitude, longitude, 1) - return addresses?.firstOrNull()?.locality - ?: addresses?.firstOrNull()?.subAdminArea - ?: addresses?.firstOrNull()?.adminArea - } + fun getCityNameFromCoordinates(latitude: Double, longitude: Double): String? { + val addresses = geocoderWrapper.getFromLocation(latitude, longitude, 1) + return addresses?.firstOrNull()?.locality + ?: addresses?.firstOrNull()?.subAdminArea + ?: addresses?.firstOrNull()?.adminArea + } - LazyColumn(modifier = modifier.fillMaxWidth(), state = listState) { + LazyColumn( + modifier = + modifier + .fillMaxWidth() + .nestedScroll(rememberNestedScrollInteropConnection()) + .testTag("worker_profiles_list"), + state = listState) { items(profiles.size) { index -> - val profile = profiles[index] - var account by remember { mutableStateOf(null) } - var distance by remember { mutableStateOf(null) } + val profile = profiles[index] + var account by remember { mutableStateOf(null) } + var distance by remember { mutableStateOf(null) } + var cityName by remember { mutableStateOf(null) } - // Get user's current location and calculate distance - val locationHelper = LocationHelper(LocalContext.current, MainActivity()) - locationHelper.getCurrentLocation { location -> - location?.let { - distance = - profile.location?.let { workerLocation -> - searchViewModel - .calculateDistance( - workerLocation.latitude, - workerLocation.longitude, - it.latitude, - it.longitude - ) - .toInt() - } - } - } + distance = + profile.location + ?.let { workerLocation -> + searchViewModel.calculateDistance( + workerLocation.latitude, + workerLocation.longitude, + baseLocation.latitude, + baseLocation.longitude) + } + ?.toInt() - // Fetch user account details - LaunchedEffect(profile.uid) { - accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> - account = fetchedAccount - } + LaunchedEffect(profile.uid) { + accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount: Account? -> + account = fetchedAccount } + } + + account?.let { acc -> + val locationName = + if (profile.location?.name.isNullOrEmpty()) "Unknown" else profile.location?.name - // Render profile card if account data is available - account?.let { acc -> - var cityName by remember { mutableStateOf(null) } - profile.location.let { - cityName = - profile.location?.let { it1 -> - getCityNameFromCoordinates(it1.latitude, profile.location.longitude) - } - val displayLoc = if (cityName != null) cityName else "Unknown" - if (displayLoc != null) { - SearchWorkerProfileResult( - modifier = - Modifier - .fillMaxWidth() - .testTag("worker_profile_result_$index") - .clickable {}, - profileImage = R.drawable.placeholder_worker, - name = "${acc.firstName} ${acc.lastName}", - category = profile.fieldOfWork, - rating = profile.rating, - reviewCount = profile.reviews.size, - location = displayLoc, - price = profile.price.roundToInt().toString(), - distance = distance, - onBookClick = { onBookClick(profile, displayLoc) }) - } - } + locationName?.let { + cityName = + profile.location?.let { it1 -> + getCityNameFromCoordinates(it1.latitude, profile.location.longitude) + } + cityName?.let { it1 -> + SearchWorkerProfileResult( + modifier = Modifier.testTag("worker_profile_result$index"), + profileImage = R.drawable.placeholder_worker, + name = "${acc.firstName} ${acc.lastName}", + category = profile.fieldOfWork, + rating = profile.reviews.map { review -> review.rating }.average(), + reviewCount = profile.reviews.size, + location = it1, + price = profile.price.roundToInt().toString(), + onBookClick = { onBookClick(profile) }, + distance = distance, + ) + } } + } } - } + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt index b33101e5..ab9a2e0f 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt @@ -11,8 +11,8 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Tab @@ -22,8 +22,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -35,25 +33,17 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp -import androidx.compose.ui.window.Popup import androidx.lifecycle.viewmodel.compose.viewModel -import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel -import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.offline.small.PreferencesViewModel import com.arygm.quickfix.model.profile.ProfileViewModel import com.arygm.quickfix.model.profile.WorkerProfile -import com.arygm.quickfix.model.profile.dataFields.AddOnService -import com.arygm.quickfix.model.profile.dataFields.IncludedService -import com.arygm.quickfix.model.profile.dataFields.Review import com.arygm.quickfix.model.quickfix.QuickFixViewModel import com.arygm.quickfix.model.search.AnnouncementViewModel import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.navigation.NavigationActions -import com.arygm.quickfix.ui.search.SearchOnBoarding import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScreen -import java.time.LocalTime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -72,199 +62,109 @@ fun QuickFixFinderScreen( quickFixViewModel: QuickFixViewModel, preferencesViewModel: PreferencesViewModel ) { - var isWindowVisible by remember { mutableStateOf(false) } - var pager by remember { mutableStateOf(true) } - var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerCategory by remember { mutableStateOf("Exterior Painter") } - var workerAddress by remember { mutableStateOf("Ecublens, VD") } - var description by remember { mutableStateOf("Worker description goes here.") } - var includedServices by remember { mutableStateOf(listOf()) } - var addonServices by remember { mutableStateOf(listOf()) } - var workerRating by remember { mutableDoubleStateOf(4.5) } - var tags by remember { mutableStateOf(listOf()) } - var reviews by remember { mutableStateOf(listOf()) } - val pagerState = rememberPagerState(pageCount = { 2 }) - val colorBackground = - if (pagerState.currentPage == 0) colorScheme.background else colorScheme.surface - val colorButton = - if (pagerState.currentPage == 1) colorScheme.background else colorScheme.surface + var isWindowVisible by remember { mutableStateOf(false) } + var pager by remember { mutableStateOf(true) } + var selectedWorker by remember { mutableStateOf(WorkerProfile()) } + val pagerState = rememberPagerState(pageCount = { 2 }) + val colorBackground = + if (pagerState.currentPage == 0) colorScheme.background else colorScheme.surface + val colorButton = if (pagerState.currentPage == 1) colorScheme.background else colorScheme.surface - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val screenHeight = maxHeight - val screenWidth = maxWidth + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenHeight = maxHeight + val screenWidth = maxWidth - Scaffold( - containerColor = colorBackground, - topBar = { - TopAppBar( - title = { - Text( - text = "Quickfix", - color = colorScheme.primary, - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier.testTag("QuickFixFinderTopBarTitle") - ) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = colorBackground), - modifier = Modifier.testTag("QuickFixFinderTopBar") - ) - }, - content = { padding -> - Column( - modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent") - .padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - val coroutineScope = rememberCoroutineScope() + Scaffold( + containerColor = colorBackground, + topBar = { + TopAppBar( + title = { + Text( + text = "Quickfix", + color = colorScheme.primary, + style = typography.headlineLarge, + modifier = Modifier.testTag("QuickFixFinderTopBarTitle")) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = colorBackground), + modifier = Modifier.testTag("QuickFixFinderTopBar")) + }, + content = { padding -> + Column( + modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { + val coroutineScope = rememberCoroutineScope() - if (pager) { - Surface( - color = colorButton, - shape = RoundedCornerShape(screenWidth * 0.05f), + if (pager) { + Surface( + color = colorButton, + shape = RoundedCornerShape(screenWidth * 0.05f), + modifier = + Modifier.padding(horizontal = screenWidth * 0.1f) + .clip(RoundedCornerShape(screenWidth * 0.05f))) { + TabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = Color.Transparent, + divider = {}, + indicator = {}, modifier = - Modifier.padding(horizontal = screenWidth * 0.1f) - .clip(RoundedCornerShape(screenWidth * 0.05f)) - ) { - TabRow( - selectedTabIndex = pagerState.currentPage, - containerColor = Color.Transparent, - divider = {}, - indicator = {}, - modifier = Modifier.padding( - horizontal = screenWidth * 0.0025f, - vertical = screenWidth * 0.0025f - ) + horizontal = screenWidth * 0.0025f, + vertical = screenWidth * 0.0025f) .align(Alignment.CenterHorizontally) - .testTag("quickFixSearchTabRow") - ) { - QuickFixScreenTab( - pagerState, coroutineScope, 0, "Search", screenWidth - ) - QuickFixScreenTab( - pagerState, coroutineScope, 1, "Announce", screenWidth - ) - } - } - } - - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - modifier = Modifier.testTag("quickFixSearchPager") - ) { page -> - when (page) { - 0 -> { - SearchOnBoarding( - onSearch = { pager = false }, - onSearchEmpty = { pager = true }, - navigationActions, - navigationActionsRoot, - searchViewModel, - accountViewModel, - categoryViewModel - ) { _ -> - val profile = - WorkerProfile( - rating = 4.8, - fieldOfWork = "Exterior Painter", - description = "Worker description goes here.", - location = Location(12.0, 12.0, "Ecublens, VD"), - quickFixes = listOf("Painting", "Gardening"), - includedServices = - listOf( - IncludedService("Painting"), - IncludedService("Gardening"), - ), - addOnServices = - listOf( - AddOnService("Furniture Assembly"), - AddOnService("Window Cleaning"), - ), - reviews = - ArrayDeque( - listOf( - Review("Bob", "nice work", 4.0), - Review("Alice", "bad work", 3.5), - ) - ), - profilePicture = "placeholder_worker", - price = 130.0, - displayName = "John Doe", - unavailability_list = emptyList(), - workingHours = Pair(LocalTime.now(), LocalTime.now()), - uid = "1234", - tags = listOf("Painter", "Gardener"), - ) - - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerCategory = profile.fieldOfWork - workerAddress = profile.location?.name ?: "Unknown" - description = profile.description - includedServices = profile.includedServices.map { it.name } - addonServices = profile.addOnServices.map { it.name } - workerRating = profile.rating - tags = profile.tags - reviews = profile.reviews.map { it.review } - - isWindowVisible = true - } - } - - 1 -> { - AnnouncementScreen( - announcementViewModel, - profileViewModel, - accountViewModel, - preferencesViewModel, - categoryViewModel, - navigationActions = navigationActions, - isUser = isUser - ) + .testTag("quickFixSearchTabRow")) { + QuickFixScreenTab( + pagerState, coroutineScope, 0, "Search", screenWidth) + QuickFixScreenTab( + pagerState, coroutineScope, 1, "Announce", screenWidth) } + } + } - else -> Text("Should never happen !") + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.testTag("quickFixSearchPager")) { page -> + when (page) { + 0 -> { + SearchOnBoarding( + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + categoryViewModel, + onProfileClick = { profile -> selectedWorker = profile }) + } + 1 -> { + AnnouncementScreen( + announcementViewModel, + profileViewModel, + accountViewModel, + preferencesViewModel, + categoryViewModel, + navigationActions = navigationActions, + isUser = isUser) } + else -> Text("Should never happen !") + } } - } - }) + } + }) - if (isWindowVisible) { - Popup( - onDismissRequest = { isWindowVisible = false }, - alignment = Alignment.Center - ) { - selectedWorker?.let { - QuickFixSlidingWindowWorker( - isVisible = isWindowVisible, - onDismiss = { isWindowVisible = false }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = initialSaved, - workerCategory = it.fieldOfWork, - workerAddress = workerAddress, - description = it.description, - includedServices = it.includedServices.map { it.name }, - addonServices = it.addOnServices.map { it.name }, - workerRating = it.reviews.map { it1 -> it1.rating }.average(), - tags = it.tags, - reviews = it.reviews.map { it.review }, - screenHeight = screenHeight, - screenWidth = screenWidth, - onContinueClick = { - quickFixViewModel.setSelectedWorkerProfile(it) - navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) - }) - } - } - } - } + QuickFixSlidingWindowWorker( + isVisible = isWindowVisible, + onDismiss = { isWindowVisible = false }, + screenHeight = screenHeight, + screenWidth = screenWidth, + onContinueClick = { + quickFixViewModel.setSelectedWorkerProfile(selectedWorker) + navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) + }, + workerProfile = selectedWorker, + ) + } } + @Composable fun QuickFixScreenTab( pagerState: PagerState, @@ -288,7 +188,7 @@ fun QuickFixScreenTab( color = if (pagerState.currentPage == currentPage) colorScheme.background else colorScheme.tertiaryContainer, - style = MaterialTheme.typography.titleMedium, + style = typography.titleMedium, modifier = Modifier.padding(horizontal = screenWidth * 0.04f, vertical = screenWidth * 0.02f) .testTag("tabText$title")) diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt index c48013ca..a0bd5b22 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt @@ -1,6 +1,5 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search -import android.widget.RatingBar import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -19,8 +18,6 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -28,7 +25,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.outlined.BookmarkBorder -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Text @@ -45,6 +41,8 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.arygm.quickfix.R +import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.ui.elements.QuickFixButton import com.arygm.quickfix.ui.elements.QuickFixSlidingWindow import com.arygm.quickfix.ui.elements.RatingBar @@ -54,312 +52,181 @@ import com.arygm.quickfix.ui.elements.RatingBar fun QuickFixSlidingWindowWorker( isVisible: Boolean, onDismiss: () -> Unit, - bannerImage: Int, - profilePicture: Int, - initialSaved: Boolean, - workerCategory: String, - workerAddress: String, - description: String, - includedServices: List, - addonServices: List, - workerRating: Double, - tags: List, - reviews: List, screenHeight: Dp, screenWidth: Dp, - onContinueClick: () -> Unit + onContinueClick: () -> Unit, + workerProfile: WorkerProfile ) { - var saved by remember { mutableStateOf(initialSaved) } + var saved by remember { mutableStateOf(false) } var showFullDescription by remember { mutableStateOf(false) } QuickFixSlidingWindow(isVisible = isVisible, onDismiss = onDismiss) { - // Content of the sliding window Column( modifier = Modifier.clip(RoundedCornerShape(topStart = 25f, bottomStart = 25f)) .fillMaxWidth() .background(colorScheme.background) .testTag("sliding_window_content")) { - - // Top Bar - Box( - modifier = - Modifier.fillMaxWidth() - .height(screenHeight * 0.23f) - .testTag("sliding_window_top_bar")) { - // Banner Image - Image( - painter = painterResource(id = bannerImage), - contentDescription = "Banner", - modifier = - Modifier.fillMaxWidth() - .height(screenHeight * 0.2f) - .testTag("sliding_window_banner_image"), - contentScale = ContentScale.Crop) - - QuickFixButton( - buttonText = if (saved) "saved" else "save", - onClickAction = { saved = !saved }, - buttonColor = colorScheme.surface, - textColor = colorScheme.onBackground, - textStyle = MaterialTheme.typography.labelMedium, - contentPadding = PaddingValues(horizontal = screenWidth * 0.01f), - modifier = - Modifier.align(Alignment.BottomEnd) - .width(screenWidth * 0.25f) - .offset(x = -(screenWidth * 0.04f)) - .testTag("sliding_window_save_button"), - leadingIcon = - if (saved) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder) - - // Profile picture overlapping the banner image - Image( - painter = painterResource(id = profilePicture), - contentDescription = "Profile Picture", - modifier = - Modifier.size(screenHeight * 0.1f) - .align(Alignment.BottomStart) - .offset(x = screenWidth * 0.04f) - .clip(CircleShape) - .testTag("sliding_window_profile_picture"), - contentScale = ContentScale.Crop) - } - - // Worker Field and Address under the profile picture - Column( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_worker_additional_info")) { - Text( - text = workerCategory, - style = MaterialTheme.typography.headlineLarge, - color = colorScheme.onBackground, - modifier = Modifier.testTag("sliding_window_worker_category")) - Text( - text = workerAddress, - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onBackground, - modifier = Modifier.testTag("sliding_window_worker_address")) - } - - // Main content should be scrollable + // Top Bar with Banner Image and Profile Picture + Box(modifier = Modifier.fillMaxWidth().height(screenHeight * 0.23f)) { + Image( + painter = painterResource(id = R.drawable.placeholder_worker), // Default fallback + contentDescription = "Banner", + modifier = Modifier.fillMaxWidth().height(screenHeight * 0.2f), + contentScale = ContentScale.Crop) + QuickFixButton( + buttonText = if (saved) "saved" else "save", + onClickAction = { saved = !saved }, + buttonColor = colorScheme.surface, + textColor = colorScheme.onBackground, + textStyle = MaterialTheme.typography.labelMedium, + contentPadding = PaddingValues(horizontal = screenWidth * 0.01f), + modifier = + Modifier.align(Alignment.BottomEnd) + .width(screenWidth * 0.25f) + .offset(x = -(screenWidth * 0.04f)), + leadingIcon = if (saved) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder) + + Image( + painter = + painterResource(id = R.drawable.placeholder_worker), // Fallback for profile + contentDescription = "Profile Picture", + modifier = + Modifier.size(screenHeight * 0.1f) + .align(Alignment.BottomStart) + .offset(x = screenWidth * 0.04f) + .clip(CircleShape), + contentScale = ContentScale.Crop) + } + + // Worker Information + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.04f)) { + Text( + text = workerProfile.fieldOfWork, + style = MaterialTheme.typography.headlineLarge, + color = colorScheme.onBackground) + Text( + text = workerProfile.location?.name ?: "Unknown Location", + style = MaterialTheme.typography.headlineSmall, + color = colorScheme.onBackground) + } + + // Scrollable Content Column( modifier = Modifier.fillMaxWidth() .verticalScroll(rememberScrollState()) - .background(colorScheme.surface) - .testTag("sliding_window_scrollable_content")) { + .background(colorScheme.surface)) { Spacer(modifier = Modifier.height(screenHeight * 0.02f)) - // Description with "Show more" functionality + // Description val descriptionText = - if (showFullDescription || description.length <= 100) { - description + if (showFullDescription || workerProfile.description.length <= 100) { + workerProfile.description } else { - description.take(100) + "..." + workerProfile.description.take(100) + "..." } - Text( text = descriptionText, style = MaterialTheme.typography.bodySmall, color = colorScheme.onSurface, - modifier = - Modifier.padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_description")) + modifier = Modifier.padding(horizontal = screenWidth * 0.04f)) - if (description.length > 100) { + if (workerProfile.description.length > 100) { Text( text = if (showFullDescription) "Show less" else "Show more", style = MaterialTheme.typography.bodySmall.copy(color = colorScheme.primary), modifier = - Modifier.padding(horizontal = screenWidth * 0.04f) - .clickable { showFullDescription = !showFullDescription } - .testTag("sliding_window_description_show_more_button")) + Modifier.padding(horizontal = screenWidth * 0.04f).clickable { + showFullDescription = !showFullDescription + }) } - // Delimiter between description and services - Spacer(modifier = Modifier.height(screenHeight * 0.02f)) - - HorizontalDivider( - modifier = - Modifier.padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_horizontal_divider_1"), - thickness = 1.dp, - color = colorScheme.onSurface.copy(alpha = 0.2f)) Spacer(modifier = Modifier.height(screenHeight * 0.02f)) // Services Section - Row( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_services_row")) { - // Included Services - Column( - modifier = - Modifier.weight(1f) - .testTag("sliding_window_included_services_column")) { - Text( - text = "Included Services", - style = MaterialTheme.typography.headlineMedium, - color = colorScheme.onBackground) - Spacer(modifier = Modifier.height(screenHeight * 0.01f)) - includedServices.forEach { service -> - Text( - text = "• $service", - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface, - modifier = Modifier.padding(bottom = screenHeight * 0.005f)) - } - } + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.04f)) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Included Services", + style = MaterialTheme.typography.headlineMedium, + color = colorScheme.onBackground) + workerProfile.includedServices.forEach { service -> + Text( + text = "• ${service.name}", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface) + } + } - Spacer(modifier = Modifier.width(screenWidth * 0.02f)) + Spacer(modifier = Modifier.width(screenWidth * 0.02f)) - // Add-On Services - Column( - modifier = - Modifier.weight(1f).testTag("sliding_window_addon_services_column")) { - Text( - text = "Add-On Services", - style = MaterialTheme.typography.headlineMedium, - color = colorScheme.primary) - Spacer(modifier = Modifier.height(screenHeight * 0.01f)) - addonServices.forEach { service -> - Text( - text = "• $service", - style = MaterialTheme.typography.bodySmall, - color = colorScheme.primary, - modifier = Modifier.padding(bottom = screenHeight * 0.005f)) - } - } + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Add-On Services", + style = MaterialTheme.typography.headlineMedium, + color = colorScheme.primary) + workerProfile.addOnServices.forEach { service -> + Text( + text = "• ${service.name}", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.primary) } + } + } Spacer(modifier = Modifier.height(screenHeight * 0.03f)) - // Continue Button with Rate/HR + // Continue Button QuickFixButton( buttonText = "Continue", onClickAction = onContinueClick, buttonColor = colorScheme.primary, textColor = colorScheme.onPrimary, textStyle = MaterialTheme.typography.labelMedium, - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_continue_button")) - - Spacer(modifier = Modifier.height(screenHeight * 0.02f)) + modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.04f)) - HorizontalDivider( - modifier = - Modifier.padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_horizontal_divider_2"), - thickness = 1.dp, - color = colorScheme.onSurface.copy(alpha = 0.2f), - ) Spacer(modifier = Modifier.height(screenHeight * 0.02f)) - // Tags Section + // Tags Text( text = "Tags", style = MaterialTheme.typography.headlineMedium, color = colorScheme.onBackground, modifier = Modifier.padding(horizontal = screenWidth * 0.04f)) - Spacer(modifier = Modifier.height(screenHeight * 0.01f)) - // Display tags using FlowRow for wrapping FlowRow( horizontalArrangement = Arrangement.spacedBy(screenWidth * 0.02f), verticalArrangement = Arrangement.spacedBy(screenHeight * 0.01f), - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_tags_flow_row"), - ) { - tags.forEach { tag -> - Text( - text = tag, - color = colorScheme.primary, - style = MaterialTheme.typography.bodySmall, - modifier = - Modifier.border( - width = 1.dp, - color = colorScheme.primary, - shape = MaterialTheme.shapes.small) - .padding( - horizontal = screenWidth * 0.02f, - vertical = screenHeight * 0.005f)) - } - } - - Spacer(modifier = Modifier.height(screenHeight * 0.02f)) + modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.04f)) { + workerProfile.tags.forEach { tag -> + Text( + text = tag, + color = colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier.border( + 1.dp, colorScheme.primary, MaterialTheme.shapes.small) + .padding(horizontal = 8.dp, vertical = 4.dp)) + } + } - HorizontalDivider( - modifier = - Modifier.padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_horizontal_divider_3"), - thickness = 1.dp, - color = colorScheme.onSurface.copy(alpha = 0.2f)) Spacer(modifier = Modifier.height(screenHeight * 0.02f)) + // Reviews Text( text = "Reviews", style = MaterialTheme.typography.headlineMedium, color = colorScheme.onBackground, modifier = Modifier.padding(horizontal = screenWidth * 0.04f)) - Spacer(modifier = Modifier.height(screenHeight * 0.01f)) - - // Star Rating Row Row( verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_star_rating_row")) { + modifier = Modifier.padding(horizontal = screenWidth * 0.04f)) { RatingBar( - workerRating.toFloat(), - modifier = Modifier.height(screenHeight * 0.03f).testTag("starsRow")) + workerProfile.rating.toFloat(), + modifier = Modifier.height(screenHeight * 0.03f)) } - Spacer(modifier = Modifier.height(screenHeight * 0.01f)) - LazyRow( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_reviews_row")) { - itemsIndexed(reviews) { index, review -> - var isExpanded by remember { mutableStateOf(false) } - val displayText = - if (isExpanded || review.length <= 100) { - review - } else { - review.take(100) + "..." - } - - Box( - modifier = - Modifier.padding(end = screenWidth * 0.02f) - .width(screenWidth * 0.6f) - .clip(RoundedCornerShape(25f)) - .background(colorScheme.background)) { - Column(modifier = Modifier.padding(screenWidth * 0.02f)) { - Text( - text = displayText, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface) - if (review.length > 100) { - Text( - text = if (isExpanded) "See less" else "See more", - style = - MaterialTheme.typography.bodySmall.copy( - color = colorScheme.primary), - modifier = - Modifier.clickable { isExpanded = !isExpanded } - .padding(top = screenHeight * 0.01f)) - } - } - } - } } - - Spacer(modifier = Modifier.height(screenHeight * 0.02f)) - }}}} \ No newline at end of file + } + } +} diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt index bd2ce36b..ac8550fa 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchFilters.kt @@ -20,10 +20,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -46,6 +43,14 @@ data class SearchFilterButtons( val applied: Boolean = false ) +data class SearchUIState( + val showFilterButtons: Boolean = false, + val showAvailabilityBottomSheet: Boolean = false, + val showServicesBottomSheet: Boolean = false, + val showPriceRangeBottomSheet: Boolean = false, + val showLocationBottomSheet: Boolean = false, +) + data class SearchFiltersState( var availabilityFilterApplied: Boolean = false, var servicesFilterApplied: Boolean = false, @@ -64,9 +69,9 @@ data class SearchFiltersState( var baseLocation: Location = Location(), var phoneLocation: Location = Location(0.0, 0.0, "Default"), var lastAppliedPriceStart: Int = 500, -var lastAppliedPriceEnd: Int = 2500, -var lastAppliedMaxDist: Int = 200, -var selectedLocationIndex: Int? = null, + var lastAppliedPriceEnd: Int = 2500, + var lastAppliedMaxDist: Int = 200, + var selectedLocationIndex: Int? = null, ) @Composable @@ -105,9 +110,9 @@ fun SearchFiltersState.reapplyFilters( if (ratingFilterApplied) { updatedProfiles = searchViewModel.sortWorkersByRating(updatedProfiles) } - if (emergencyFilterApplied) { + if (emergencyFilterApplied) { updatedProfiles = searchViewModel.emergencyFilter(updatedProfiles, baseLocation) - } + } return updatedProfiles } @@ -188,34 +193,34 @@ fun SearchFiltersState.getFilterButtons( leadingIcon = Icons.Default.MonetizationOn, trailingIcon = Icons.Default.KeyboardArrowDown, applied = priceFilterApplied), - - SearchFilterButtons( - onClick = { + SearchFilterButtons( + onClick = { if (emergencyFilterApplied) { - emergencyFilterApplied = false - reapplyFilters() + emergencyFilterApplied = false + onProfilesUpdated(reapplyFilters(workerProfiles, searchViewModel)) } else { - lastAppliedMaxDist = 200 - lastAppliedPriceStart = 500 - lastAppliedPriceEnd = 2500 - selectedLocationIndex = null - selectedServices = emptyList() - availabilityFilterApplied = false - priceFilterApplied = false - locationFilterApplied = false - ratingFilterApplied = false - servicesFilterApplied = false - baseLocation = phoneLocation - filteredWorkerProfiles = workerProfiles - filteredWorkerProfiles = - searchViewModel.emergencyFilter(filteredWorkerProfiles, baseLocation) - emergencyFilterApplied = true + lastAppliedMaxDist = 200 + lastAppliedPriceStart = 500 + lastAppliedPriceEnd = 2500 + selectedLocationIndex = null + selectedServices = emptyList() + availabilityFilterApplied = false + priceFilterApplied = false + locationFilterApplied = false + ratingFilterApplied = false + servicesFilterApplied = false + baseLocation = phoneLocation + + val emergencyFilteredProfiles = + searchViewModel.emergencyFilter(workerProfiles, baseLocation) + emergencyFilterApplied = true + onProfilesUpdated(emergencyFilteredProfiles) } - }, - text = "Emergency", - leadingIcon = Icons.Default.Warning, - trailingIcon = if (emergencyFilterApplied) Icons.Default.Clear else null, - applied = emergencyFilterApplied)) + }, + text = "Emergency", + leadingIcon = Icons.Default.Warning, + trailingIcon = if (emergencyFilterApplied) Icons.Default.Clear else null, + applied = emergencyFilterApplied)) } @Composable diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index eb26ae44..9e20dfca 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -1,6 +1,5 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search -import android.util.Log import android.widget.Toast import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -23,6 +22,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,13 +34,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.UserProfile import com.arygm.quickfix.model.profile.WorkerProfile -import com.arygm.quickfix.model.quickfix.QuickFixViewModel import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.elements.ChooseServiceTypeSheet import com.arygm.quickfix.ui.elements.QuickFixAvailabilityBottomSheet @@ -50,51 +48,46 @@ import com.arygm.quickfix.ui.elements.QuickFixPriceRangeBottomSheet import com.arygm.quickfix.ui.elements.QuickFixTextFieldCustom import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography -import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScreen import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserTopLevelDestinations @Composable fun SearchOnBoarding( - onSearch: () -> Unit, - onSearchEmpty: () -> Unit, navigationActions: NavigationActions, navigationActionsRoot: NavigationActions, searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, categoryViewModel: CategoryViewModel, onProfileClick: (WorkerProfile) -> Unit, - quickFixViewModel: QuickFixViewModel ) { - val profiles = searchViewModel.workerProfilesSuggestions.collectAsState() + val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } + val workerProfiles by searchViewModel.workerProfilesSuggestions.collectAsState() + var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } val context = LocalContext.current - val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() + val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") + var locationFilterApplied by remember { mutableStateOf(false) } + var lastAppliedMaxDist by remember { mutableIntStateOf(200) } val focusManager = LocalFocusManager.current + var selectedLocation by remember { mutableStateOf(Location()) } val categories = categoryViewModel.categories.collectAsState().value - Log.d("SearchOnBoarding", "Categories: $categories") val itemCategories = remember { categories } val expandedStates = remember { mutableStateListOf(*BooleanArray(itemCategories.size) { false }.toTypedArray()) } val listState = rememberLazyListState() + var selectedLocationIndex by remember { mutableStateOf(null) } var searchQuery by remember { mutableStateOf("") } - val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() // Filtering logic val filterState = rememberSearchFiltersState() - var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } - var selectedWorker by remember { mutableStateOf(null) } + var baseLocation by remember { mutableStateOf(filterState.phoneLocation) } + var maxDistance by remember { mutableIntStateOf(0) } fun updateFilteredProfiles() { filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) } - var showFilterButtons by remember { mutableStateOf(false) } - var showAvailabilityBottomSheet by remember { mutableStateOf(false) } - var showServicesBottomSheet by remember { mutableStateOf(false) } - var showPriceRangeBottomSheet by remember { mutableStateOf(false) } - var showLocationBottomSheet by remember { mutableStateOf(false) } // Build filter buttons val listOfButtons = filterState.getFilterButtons( @@ -102,18 +95,14 @@ fun SearchOnBoarding( filteredProfiles = filteredWorkerProfiles, searchViewModel = searchViewModel, onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, - onShowAvailabilityBottomSheet = { showAvailabilityBottomSheet = true }, - onShowServicesBottomSheet = { showServicesBottomSheet = true }, - onShowPriceRangeBottomSheet = { showPriceRangeBottomSheet = true }, - onShowLocationBottomSheet = { showLocationBottomSheet = true }, - ) - // Variables for WorkerSlidingWindowContent - // These will be set when a worker profile is selected - var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerAddress by remember { mutableStateOf("") } - + onShowAvailabilityBottomSheet = { + setUiState(uiState.copy(showAvailabilityBottomSheet = true)) + }, + onShowServicesBottomSheet = { setUiState(uiState.copy(showServicesBottomSheet = true)) }, + onShowPriceRangeBottomSheet = { + setUiState(uiState.copy(showPriceRangeBottomSheet = true)) + }, + onShowLocationBottomSheet = { setUiState(uiState.copy(showLocationBottomSheet = true)) }) BoxWithConstraints { val widthRatio = maxWidth.value / 411f val heightRatio = maxHeight.value / 860f @@ -149,16 +138,6 @@ fun SearchOnBoarding( onValueChange = { searchQuery = it searchViewModel.searchEngine(it) - if (it.isEmpty()) { - onSearchEmpty() - // When search is empty, we can reset filteredWorkerProfiles to - // original - filteredWorkerProfiles = workerProfiles - } else { - onSearch() - // If needed, reapply filters here if filters are set - updateFilteredProfiles() - } }, shape = CircleShape, textStyle = poppinsTypography.bodyMedium, @@ -210,40 +189,34 @@ fun SearchOnBoarding( verticalAlignment = Alignment.CenterVertically, ) { FilterRow( - showFilterButtons = showFilterButtons, - toggleFilterButtons = { showFilterButtons = !showFilterButtons }, + showFilterButtons = uiState.showFilterButtons, + toggleFilterButtons = { + setUiState(uiState.copy(showFilterButtons = !uiState.showFilterButtons)) + }, listOfButtons = listOfButtons, modifier = Modifier.padding(bottom = screenHeight * 0.01f), screenWidth = screenWidth, screenHeight = screenHeight) } - - ProfileResults( - profiles = profiles.value, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - listState = listState, - heightRatio = heightRatio, - onBookClick = { selectedProfile, locName -> - selectedWorker = selectedProfile as WorkerProfile - // Set up variables for WorkerSlidingWindowContent - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerAddress = locName - isWindowVisible = true - }) + } } + ProfileResults( + profiles = filteredWorkerProfiles, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + listState = listState, + onBookClick = { selectedProfile -> onProfileClick(selectedProfile) }, + baseLocation = baseLocation) } - }}, + }, modifier = Modifier.pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) }) QuickFixAvailabilityBottomSheet( - showAvailabilityBottomSheet, - onDismissRequest = { showAvailabilityBottomSheet = false }, + uiState.showAvailabilityBottomSheet, + onDismissRequest = { setUiState(uiState.copy(showAvailabilityBottomSheet = false)) }, onOkClick = { days, hour, minute -> filterState.selectedDays = days filterState.selectedHour = hour @@ -260,32 +233,34 @@ fun SearchOnBoarding( }, clearEnabled = filterState.availabilityFilterApplied) - ChooseServiceTypeSheet( - showServicesBottomSheet, - emptyList(), - selectedServices = filterState.selectedServices, - onApplyClick = { services -> - filterState.selectedServices = services - filterState.servicesFilterApplied = true - updateFilteredProfiles() - }, - onDismissRequest = { showServicesBottomSheet = false }, - onClearClick = { - filterState.selectedServices = emptyList() - filterState.servicesFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = filterState.servicesFilterApplied) + searchSubcategory?.let { + ChooseServiceTypeSheet( + uiState.showServicesBottomSheet, + it.tags, + selectedServices = filterState.selectedServices, + onApplyClick = { services -> + filterState.selectedServices = services + filterState.servicesFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { setUiState(uiState.copy(showServicesBottomSheet = false)) }, + onClearClick = { + filterState.selectedServices = emptyList() + filterState.servicesFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.servicesFilterApplied) + } QuickFixPriceRangeBottomSheet( - showPriceRangeBottomSheet, + uiState.showPriceRangeBottomSheet, onApplyClick = { start, end -> filterState.selectedPriceStart = start filterState.selectedPriceEnd = end filterState.priceFilterApplied = true updateFilteredProfiles() }, - onDismissRequest = { showPriceRangeBottomSheet = false }, + onDismissRequest = { setUiState(uiState.copy(showPriceRangeBottomSheet = false)) }, onClearClick = { filterState.selectedPriceStart = 0 filterState.selectedPriceEnd = 0 @@ -295,25 +270,39 @@ fun SearchOnBoarding( clearEnabled = filterState.priceFilterApplied) QuickFixLocationFilterBottomSheet( - showLocationBottomSheet, + uiState.showLocationBottomSheet, userProfile = userProfile, phoneLocation = filterState.phoneLocation, + selectedLocationIndex = selectedLocationIndex, onApplyClick = { location, max -> - filterState.selectedLocation = location - if (location == com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) { + selectedLocation = location + lastAppliedMaxDist = max + baseLocation = location + maxDistance = max + selectedLocationIndex = userProfile.locations.indexOf(location) + 1 + + if (location == Location(0.0, 0.0, "Default")) { Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() } - filterState.baseLocation = location - filterState.maxDistance = max - filterState.locationFilterApplied = true - updateFilteredProfiles() + if (locationFilterApplied) { + updateFilteredProfiles() + } else { + filteredWorkerProfiles = + searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) + } + locationFilterApplied = true }, - onDismissRequest = { showLocationBottomSheet = false }, + onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, onClearClick = { - filterState.baseLocation = filterState.phoneLocation - filterState.selectedLocation = com.arygm.quickfix.model.locations.Location() - filterState.maxDistance = 0 - filterState.locationFilterApplied = false + baseLocation = filterState.phoneLocation + lastAppliedMaxDist = 200 + selectedLocation = Location() + maxDistance = 0 + selectedLocationIndex = null + locationFilterApplied = false updateFilteredProfiles() }, - clearEnabled = filterState.locationFilterApplied)}} + clearEnabled = locationFilterApplied, + end = lastAppliedMaxDist) + } +} diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index 56d374c7..fccb6a62 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -1,58 +1,21 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search -import android.annotation.SuppressLint import android.util.Log import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Handyman -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.LocationSearching -import androidx.compose.material.icons.filled.MonetizationOn import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.filled.WorkspacePremium -import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Scaffold @@ -62,55 +25,34 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import com.arygm.quickfix.MainActivity -import com.arygm.quickfix.R -import com.arygm.quickfix.model.account.Account import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.offline.small.PreferencesViewModel import com.arygm.quickfix.model.profile.ProfileViewModel import com.arygm.quickfix.model.profile.UserProfile import com.arygm.quickfix.model.profile.WorkerProfile -import com.arygm.quickfix.model.profile.dataFields.AddOnService -import com.arygm.quickfix.model.profile.dataFields.IncludedService -import com.arygm.quickfix.model.profile.dataFields.Review import com.arygm.quickfix.model.quickfix.QuickFixViewModel import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.elements.ChooseServiceTypeSheet import com.arygm.quickfix.ui.elements.QuickFixAvailabilityBottomSheet -import com.arygm.quickfix.ui.elements.QuickFixButton import com.arygm.quickfix.ui.elements.QuickFixLocationFilterBottomSheet import com.arygm.quickfix.ui.elements.QuickFixPriceRangeBottomSheet -import com.arygm.quickfix.ui.elements.QuickFixSlidingWindow -import com.arygm.quickfix.ui.elements.RatingBar import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScreen -import com.arygm.quickfix.utils.GeocoderWrapper import com.arygm.quickfix.utils.LocationHelper import com.arygm.quickfix.utils.loadUserId -import java.time.LocalDate -import kotlin.math.roundToInt -import java.time.LocalTime @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -119,55 +61,18 @@ fun SearchWorkerResult( searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, userProfileViewModel: ProfileViewModel, - preferencesViewModel: PreferencesViewModel, quickFixViewModel: QuickFixViewModel, - geocoderWrapper: GeocoderWrapper = GeocoderWrapper(LocalContext.current), - locationHelper: LocationHelper = LocationHelper(LocalContext.current, MainActivity()) + preferencesViewModel: PreferencesViewModel, ) { - fun getCityNameFromCoordinates(latitude: Double, longitude: Double): String? { - val addresses = geocoderWrapper.getFromLocation(latitude, longitude, 1) - return addresses?.firstOrNull()?.locality - ?: addresses?.firstOrNull()?.subAdminArea - ?: addresses?.firstOrNull()?.adminArea - } - var phoneLocation by remember { - mutableStateOf(com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) - } - var baseLocation by remember { mutableStateOf(phoneLocation) } + val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } + var isWindowVisible by remember { mutableStateOf(false) } val context = LocalContext.current val locationHelper = LocationHelper(context, MainActivity()) - - // State that manages all filters and their applied logic + var selectedWorkerProfile by remember { mutableStateOf(WorkerProfile()) } val filterState = rememberSearchFiltersState() - - val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() - var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } - - var showFilterButtons by remember { mutableStateOf(false) } - var showAvailabilityBottomSheet by remember { mutableStateOf(false) } - var showServicesBottomSheet by remember { mutableStateOf(false) } - var showPriceRangeBottomSheet by remember { mutableStateOf(false) } - var showLocationBottomSheet by remember { mutableStateOf(false) } - var selectedLocationIndex by remember { mutableStateOf(null) } - - var isWindowVisible by remember { mutableStateOf(false) } - - var bannerImage by remember { mutableIntStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableIntStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerCategory by remember { mutableStateOf("Exterior Painter") } - var workerAddress by remember { mutableStateOf("Ecublens, VD") } - var description by remember { mutableStateOf("Worker description goes here.") } - var includedServices by remember { mutableStateOf(listOf()) } - var addonServices by remember { mutableStateOf(listOf()) } - var workerRating by remember { mutableDoubleStateOf(4.5) } - var tags by remember { mutableStateOf(listOf()) } - var reviews by remember { mutableStateOf(listOf()) } - + var baseLocation by remember { mutableStateOf(filterState.phoneLocation) } var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") var uid by remember { mutableStateOf("Loading...") } - - val searchQuery by searchViewModel.searchQuery.collectAsState() val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() // Fetch user and set base location @@ -188,7 +93,6 @@ fun SearchWorkerResult( if (location != null) { val userLoc = Location(location.latitude, location.longitude, "Phone Location") filterState.phoneLocation = userLoc - filterState.baseLocation = userLoc } else { Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() } @@ -198,107 +102,36 @@ fun SearchWorkerResult( } } - var selectedWorker by remember { mutableStateOf(WorkerProfile()) } - var selectedCityName by remember { mutableStateOf(null) } - var showFilterButtons by remember { mutableStateOf(false) } - var showAvailabilityBottomSheet by remember { mutableStateOf(false) } - var showServicesBottomSheet by remember { mutableStateOf(false) } - var showPriceRangeBottomSheet by remember { mutableStateOf(false) } - var showLocationBottomSheet by remember { mutableStateOf(false) } val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() - Log.d("Chill guy", workerProfiles.size.toString()) var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } - val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() val searchCategory by searchViewModel.searchCategory.collectAsState() - var availabilityFilterApplied by remember { mutableStateOf(false) } - var servicesFilterApplied by remember { mutableStateOf(false) } - var priceFilterApplied by remember { mutableStateOf(false) } var locationFilterApplied by remember { mutableStateOf(false) } - var ratingFilterApplied by remember { mutableStateOf(false) } - var emergencyFilterApplied by remember { mutableStateOf(false) } - - var selectedDays by remember { mutableStateOf(emptyList()) } - var selectedHour by remember { mutableStateOf(0) } - var selectedMinute by remember { mutableStateOf(0) } - var selectedServices by remember { mutableStateOf(emptyList()) } - var selectedPriceStart by remember { mutableStateOf(0) } - var selectedPriceEnd by remember { mutableStateOf(0) } - var selectedLocation by remember { mutableStateOf(com.arygm.quickfix.model.locations.Location()) } - var maxDistance by remember { mutableStateOf(0) } + var selectedLocation by remember { mutableStateOf(Location()) } + var maxDistance by remember { mutableIntStateOf(0) } var selectedLocationIndex by remember { mutableStateOf(null) } - var lastAppliedPriceStart by remember { mutableStateOf(500) } - var lastAppliedPriceEnd by remember { mutableStateOf(2500) } - var lastAppliedMaxDist by remember { mutableStateOf(200) } - - fun reapplyFilters() { - Log.d("Chill guy", "entered") - var updatedProfiles = workerProfiles - - if (availabilityFilterApplied) { - updatedProfiles = - searchViewModel.filterWorkersByAvailability( - updatedProfiles, selectedDays, selectedHour, selectedMinute) - } - - if (servicesFilterApplied) { - updatedProfiles = searchViewModel.filterWorkersByServices(updatedProfiles, selectedServices) - } - - if (priceFilterApplied) { - updatedProfiles = - searchViewModel.filterWorkersByPriceRange( - updatedProfiles, selectedPriceStart, selectedPriceEnd) - } - - if (locationFilterApplied) { - updatedProfiles = - searchViewModel.filterWorkersByDistance(updatedProfiles, selectedLocation, maxDistance) - } - - if (ratingFilterApplied) { - updatedProfiles = searchViewModel.sortWorkersByRating(updatedProfiles) - } + var lastAppliedMaxDist by remember { mutableIntStateOf(200) } - if (emergencyFilterApplied) { - updatedProfiles = searchViewModel.emergencyFilter(updatedProfiles, baseLocation) - } - - Log.d("Chill guy", updatedProfiles.size.toString()) - filteredWorkerProfiles = updatedProfiles + val listState = rememberLazyListState() + fun updateFilteredProfiles() { + filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) } - val listState = rememberLazyListState() - fun updateFilteredProfiles() { - filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) - } - val listOfButtons = + val listOfButtons = filterState.getFilterButtons( workerProfiles = workerProfiles, filteredProfiles = filteredWorkerProfiles, searchViewModel = searchViewModel, onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, - onShowAvailabilityBottomSheet = { showAvailabilityBottomSheet = true }, - onShowServicesBottomSheet = { showServicesBottomSheet = true }, - onShowPriceRangeBottomSheet = { showPriceRangeBottomSheet = true }, - onShowLocationBottomSheet = { showLocationBottomSheet = true }, - ) - - // ==========================================================================// - // ============ TODO: REMOVE NO-DATA WHEN BACKEND IS IMPLEMENTED ============// - // ==========================================================================// - - val bannerImage = R.drawable.moroccan_flag - val profilePicture = R.drawable.placeholder_worker - - // ==========================================================================// - // ==========================================================================// - // ==========================================================================// - - var isWindowVisible by remember { mutableStateOf(false) } - var saved by remember { mutableStateOf(false) } - + onShowAvailabilityBottomSheet = { + setUiState(uiState.copy(showAvailabilityBottomSheet = true)) + }, + onShowServicesBottomSheet = { setUiState(uiState.copy(showServicesBottomSheet = true)) }, + onShowPriceRangeBottomSheet = { + setUiState(uiState.copy(showPriceRangeBottomSheet = true)) + }, + onShowLocationBottomSheet = { setUiState(uiState.copy(showLocationBottomSheet = true)) }) // Wrap everything in a Box to allow overlay BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val screenHeight = maxHeight @@ -354,85 +187,24 @@ fun SearchWorkerResult( ) } - Row( - modifier = - Modifier.fillMaxWidth() - .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) - .padding(horizontal = screenWidth * 0.02f) - .wrapContentHeight() - .testTag("filter_buttons_row"), - verticalAlignment = Alignment.CenterVertically, - ) { - FilterRow( - showFilterButtons = showFilterButtons, - toggleFilterButtons = { showFilterButtons = !showFilterButtons }, - listOfButtons = listOfButtons, - modifier = Modifier.padding(bottom = screenHeight * 0.01f), - screenWidth = screenWidth, - screenHeight = screenHeight) - } - ProfileResults( - modifier = Modifier.testTag("worker_profiles_list"), profiles = filteredWorkerProfiles, - listState = listState, + modifier = Modifier.fillMaxWidth().weight(1f), searchViewModel = searchViewModel, accountViewModel = accountViewModel, + listState = listState, onBookClick = { selectedProfile -> - // Mock data for demonstration - val profile = - WorkerProfile( - rating = 4.8, - fieldOfWork = "Exterior Painter", - description = "Worker description goes here.", - location = Location(12.0, 12.0, "Ecublens, VD"), - quickFixes = listOf("Painting", "Gardening"), - includedServices = - listOf( - IncludedService("Painting"), - IncludedService("Gardening"), - ), - addOnServices = - listOf( - AddOnService("Furniture Assembly"), - AddOnService("Window Cleaning"), - ), - reviews = - ArrayDeque( - listOf( - Review("Bob", "nice work", 4.0), - Review("Alice", "bad work", 3.5), - )), - profilePicture = "placeholder_worker", - price = 130.0, - displayName = "John Doe", - unavailability_list = emptyList(), - workingHours = Pair(LocalTime.now(), LocalTime.now()), - uid = "1234", - tags = listOf("Painter", "Gardener"), - ) - - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerCategory = profile.fieldOfWork - workerAddress = profile.location?.name ?: "Unknown" - description = profile.description - includedServices = profile.includedServices.map { it.name } - addonServices = profile.addOnServices.map { it.name } - workerRating = profile.rating - tags = profile.tags - reviews = profile.reviews.map { it.review } - isWindowVisible = true - }) + selectedWorkerProfile = selectedProfile + }, + baseLocation = baseLocation) } } // Bottom sheets for filters QuickFixAvailabilityBottomSheet( - showAvailabilityBottomSheet, - onDismissRequest = { showAvailabilityBottomSheet = false }, + uiState.showAvailabilityBottomSheet, + onDismissRequest = { setUiState(uiState.copy(showAvailabilityBottomSheet = false)) }, onOkClick = { days, hour, minute -> filterState.selectedDays = days filterState.selectedHour = hour @@ -451,7 +223,7 @@ fun SearchWorkerResult( searchSubcategory?.let { ChooseServiceTypeSheet( - showServicesBottomSheet, + uiState.showServicesBottomSheet, it.tags, selectedServices = filterState.selectedServices, onApplyClick = { services -> @@ -459,7 +231,7 @@ fun SearchWorkerResult( filterState.servicesFilterApplied = true updateFilteredProfiles() }, - onDismissRequest = { showServicesBottomSheet = false }, + onDismissRequest = { setUiState(uiState.copy(showServicesBottomSheet = false)) }, onClearClick = { filterState.selectedServices = emptyList() filterState.servicesFilterApplied = false @@ -469,14 +241,14 @@ fun SearchWorkerResult( } QuickFixPriceRangeBottomSheet( - showPriceRangeBottomSheet, + uiState.showPriceRangeBottomSheet, onApplyClick = { start, end -> filterState.selectedPriceStart = start filterState.selectedPriceEnd = end filterState.priceFilterApplied = true updateFilteredProfiles() }, - onDismissRequest = { showPriceRangeBottomSheet = false }, + onDismissRequest = { setUiState(uiState.copy(showPriceRangeBottomSheet = false)) }, onClearClick = { filterState.selectedPriceStart = 0 filterState.selectedPriceEnd = 0 @@ -485,61 +257,51 @@ fun SearchWorkerResult( }, clearEnabled = filterState.priceFilterApplied) - userProfile?.let { - QuickFixLocationFilterBottomSheet( - showLocationBottomSheet, - userProfile = it, - phoneLocation = phoneLocation, - selectedLocationIndex = selectedLocationIndex, - onApplyClick = { location, max -> - selectedLocation = location - lastAppliedMaxDist = max - baseLocation = location - maxDistance = max - selectedLocationIndex = userProfile!!.locations.indexOf(location) + 1 - - if (location == com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() - } - if (locationFilterApplied) { - reapplyFilters() - } else { - filteredWorkerProfiles = - searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) - } - locationFilterApplied = true - }, - onDismissRequest = { showLocationBottomSheet = false }, - onClearClick = { - baseLocation = phoneLocation - lastAppliedMaxDist = 200 - selectedLocation = com.arygm.quickfix.model.locations.Location() - maxDistance = 0 - selectedLocationIndex = null - locationFilterApplied = false - reapplyFilters() - }, - clearEnabled = locationFilterApplied, - end = lastAppliedMaxDist) - } + QuickFixLocationFilterBottomSheet( + uiState.showLocationBottomSheet, + userProfile = userProfile, + phoneLocation = filterState.phoneLocation, + selectedLocationIndex = selectedLocationIndex, + onApplyClick = { location, max -> + selectedLocation = location + lastAppliedMaxDist = max + baseLocation = location + maxDistance = max + selectedLocationIndex = userProfile.locations.indexOf(location) + 1 + + if (location == Location(0.0, 0.0, "Default")) { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } + if (locationFilterApplied) { + updateFilteredProfiles() + } else { + filteredWorkerProfiles = + searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) + } + locationFilterApplied = true + }, + onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, + onClearClick = { + baseLocation = filterState.phoneLocation + lastAppliedMaxDist = 200 + selectedLocation = Location() + maxDistance = 0 + selectedLocationIndex = null + locationFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = locationFilterApplied, + end = lastAppliedMaxDist) QuickFixSlidingWindowWorker( isVisible = isWindowVisible, onDismiss = { isWindowVisible = false }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = initialSaved, - workerCategory = workerCategory, - workerAddress = workerAddress, - description = description, - includedServices = includedServices, - addonServices = addonServices, - workerRating = workerRating, - tags = tags, - reviews = reviews, screenHeight = maxHeight, screenWidth = maxWidth, onContinueClick = { - quickFixViewModel.setSelectedWorkerProfile(selectedWorker) - navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING)}) + quickFixViewModel.setSelectedWorkerProfile(selectedWorkerProfile) + navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) + }, + workerProfile = selectedWorkerProfile, + ) } } From 1c034236d3192a93f5464fac873d39a96c6b77d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Thu, 19 Dec 2024 05:05:32 +0100 Subject: [PATCH 11/15] Fix: update tests for two files. - The two files are QuickFixSlidingWindowWorker.kt and ProfileResults.kt --- .../quickfix/ui/search/ProfileResultsTest.kt | 4 +- .../search/QuickFixSlidingWindowWorkerTest.kt | 7 +- .../userModeUI/search/ProfileResults.kt | 105 +++++++++--------- .../search/QuickFixSlidingWindowWorker.kt | 67 ++++++----- .../userModeUI/search/SearchOnBoarding.kt | 3 +- .../userModeUI/search/SearchWorkerResult.kt | 26 ++++- 6 files changed, 122 insertions(+), 90 deletions(-) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt index 4f2b4821..2bfa074e 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt @@ -10,7 +10,6 @@ import com.arygm.quickfix.model.account.AccountRepositoryFirestore import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryRepositoryFirestore import com.arygm.quickfix.model.category.CategoryViewModel -import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.search.SearchViewModel @@ -104,8 +103,7 @@ class ProfileResultsTest { listState = rememberLazyListState(), searchViewModel = searchViewModel, accountViewModel = accountViewModel, - onBookClick = { _ -> }, - baseLocation = mock(Location::class.java)) + onBookClick = { _, _ -> }) } // Allow coroutines to complete diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt index 2b9956f7..8412b5c4 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt @@ -4,6 +4,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.printToLog import androidx.compose.ui.unit.dp import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.profile.dataFields.AddOnService @@ -64,8 +66,9 @@ class QuickFixSlidingWindowWorkerTest { .assertIsDisplayed() // Check each included service + composeTestRule.onRoot().printToLog("includedServices") includedServices.forEach { service -> - composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() + composeTestRule.onNodeWithText("• ${service.name}").assertExists().assertIsDisplayed() } } @@ -114,7 +117,7 @@ class QuickFixSlidingWindowWorkerTest { // Check each add-on service addOnServices.forEach { service -> - composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() + composeTestRule.onNodeWithText("• ${service}").assertExists().assertIsDisplayed() } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt index 26117627..e9308a11 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt @@ -1,5 +1,6 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -10,29 +11,27 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.testTag +import com.arygm.quickfix.MainActivity import com.arygm.quickfix.R import com.arygm.quickfix.model.account.Account import com.arygm.quickfix.model.account.AccountViewModel -import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.utils.GeocoderWrapper +import com.arygm.quickfix.utils.LocationHelper import kotlin.math.roundToInt @Composable fun ProfileResults( modifier: Modifier = Modifier, profiles: List, - searchViewModel: SearchViewModel, listState: LazyListState, + searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, geocoderWrapper: GeocoderWrapper = GeocoderWrapper(LocalContext.current), - onBookClick: (WorkerProfile) -> Unit, - baseLocation: Location + onBookClick: (WorkerProfile, String) -> Unit ) { fun getCityNameFromCoordinates(latitude: Double, longitude: Double): String? { val addresses = geocoderWrapper.getFromLocation(latitude, longitude, 1) @@ -41,61 +40,61 @@ fun ProfileResults( ?: addresses?.firstOrNull()?.adminArea } - LazyColumn( - modifier = - modifier - .fillMaxWidth() - .nestedScroll(rememberNestedScrollInteropConnection()) - .testTag("worker_profiles_list"), - state = listState) { - items(profiles.size) { index -> - val profile = profiles[index] - var account by remember { mutableStateOf(null) } - var distance by remember { mutableStateOf(null) } - var cityName by remember { mutableStateOf(null) } + LazyColumn(modifier = modifier.fillMaxWidth(), state = listState) { + items(profiles.size) { index -> + val profile = profiles[index] + var account by remember { mutableStateOf(null) } + var distance by remember { mutableStateOf(null) } + // Get user's current location and calculate distance + val locationHelper = LocationHelper(LocalContext.current, MainActivity()) + locationHelper.getCurrentLocation { location -> + location?.let { distance = - profile.location - ?.let { workerLocation -> - searchViewModel.calculateDistance( + profile.location?.let { workerLocation -> + searchViewModel + .calculateDistance( workerLocation.latitude, workerLocation.longitude, - baseLocation.latitude, - baseLocation.longitude) - } - ?.toInt() - - LaunchedEffect(profile.uid) { - accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount: Account? -> - account = fetchedAccount - } - } + it.latitude, + it.longitude) + .toInt() + } + } + } - account?.let { acc -> - val locationName = - if (profile.location?.name.isNullOrEmpty()) "Unknown" else profile.location?.name + // Fetch user account details + LaunchedEffect(profile.uid) { + accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> + account = fetchedAccount + } + } - locationName?.let { - cityName = - profile.location?.let { it1 -> - getCityNameFromCoordinates(it1.latitude, profile.location.longitude) - } - cityName?.let { it1 -> - SearchWorkerProfileResult( - modifier = Modifier.testTag("worker_profile_result$index"), - profileImage = R.drawable.placeholder_worker, - name = "${acc.firstName} ${acc.lastName}", - category = profile.fieldOfWork, - rating = profile.reviews.map { review -> review.rating }.average(), - reviewCount = profile.reviews.size, - location = it1, - price = profile.price.roundToInt().toString(), - onBookClick = { onBookClick(profile) }, - distance = distance, - ) + // Render profile card if account data is available + account?.let { acc -> + var cityName by remember { mutableStateOf(null) } + profile.location.let { + cityName = + profile.location?.let { it1 -> + getCityNameFromCoordinates(it1.latitude, profile.location.longitude) } - } + val displayLoc = if (cityName != null) cityName else "Unknown" + if (displayLoc != null) { + SearchWorkerProfileResult( + modifier = + Modifier.fillMaxWidth().testTag("worker_profile_result_$index").clickable {}, + profileImage = R.drawable.placeholder_worker, + name = "${acc.firstName} ${acc.lastName}", + category = profile.fieldOfWork, + rating = profile.rating, + reviewCount = profile.reviews.size, + location = displayLoc, + price = profile.price.roundToInt().toString(), + distance = distance, + onBookClick = { onBookClick(profile, displayLoc) }) } } } + } + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt index a0bd5b22..9d3012e8 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt @@ -104,11 +104,14 @@ fun QuickFixSlidingWindowWorker( Text( text = workerProfile.fieldOfWork, style = MaterialTheme.typography.headlineLarge, - color = colorScheme.onBackground) + color = colorScheme.onBackground, + modifier = Modifier.testTag("sliding_window_worker_category")) + Text( text = workerProfile.location?.name ?: "Unknown Location", style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onBackground) + color = colorScheme.onBackground, + modifier = Modifier.testTag("sliding_window_worker_address")) } // Scrollable Content @@ -116,7 +119,8 @@ fun QuickFixSlidingWindowWorker( modifier = Modifier.fillMaxWidth() .verticalScroll(rememberScrollState()) - .background(colorScheme.surface)) { + .background(colorScheme.surface) + .testTag("sliding_window_scrollable_content")) { Spacer(modifier = Modifier.height(screenHeight * 0.02f)) // Description @@ -146,33 +150,37 @@ fun QuickFixSlidingWindowWorker( // Services Section Row(modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.04f)) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Included Services", - style = MaterialTheme.typography.headlineMedium, - color = colorScheme.onBackground) - workerProfile.includedServices.forEach { service -> - Text( - text = "• ${service.name}", - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface) - } - } + Column( + modifier = + Modifier.weight(1f).testTag("sliding_window_included_services_column")) { + Text( + text = "Included Services", + style = MaterialTheme.typography.headlineMedium, + color = colorScheme.onBackground) + workerProfile.includedServices.forEach { service -> + Text( + text = "• ${service.name}", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface) + } + } Spacer(modifier = Modifier.width(screenWidth * 0.02f)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Add-On Services", - style = MaterialTheme.typography.headlineMedium, - color = colorScheme.primary) - workerProfile.addOnServices.forEach { service -> - Text( - text = "• ${service.name}", - style = MaterialTheme.typography.bodySmall, - color = colorScheme.primary) - } - } + Column( + modifier = + Modifier.weight(1f).testTag("sliding_window_addon_services_column")) { + Text( + text = "Add-On Services", + style = MaterialTheme.typography.headlineMedium, + color = colorScheme.primary) + workerProfile.addOnServices.forEach { service -> + Text( + text = "• ${service.name}", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.primary) + } + } } Spacer(modifier = Modifier.height(screenHeight * 0.03f)) @@ -198,7 +206,10 @@ fun QuickFixSlidingWindowWorker( FlowRow( horizontalArrangement = Arrangement.spacedBy(screenWidth * 0.02f), verticalArrangement = Arrangement.spacedBy(screenHeight * 0.01f), - modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.04f)) { + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_tags_flow_row")) { workerProfile.tags.forEach { tag -> Text( text = tag, diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index 9e20dfca..b7e532f9 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -205,8 +205,7 @@ fun SearchOnBoarding( searchViewModel = searchViewModel, accountViewModel = accountViewModel, listState = listState, - onBookClick = { selectedProfile -> onProfileClick(selectedProfile) }, - baseLocation = baseLocation) + onBookClick = { selectedProfile, _ -> onProfileClick(selectedProfile) }) } }, modifier = diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index fccb6a62..4a536ced 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -5,9 +5,11 @@ import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -32,6 +34,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp @@ -187,17 +190,36 @@ fun SearchWorkerResult( ) } + Row( + modifier = + Modifier.fillMaxWidth() + .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) + .padding(horizontal = screenWidth * 0.02f) + .wrapContentHeight() + .testTag("filter_buttons_row"), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterRow( + showFilterButtons = uiState.showFilterButtons, + toggleFilterButtons = { + setUiState(uiState.copy(showFilterButtons = !uiState.showFilterButtons)) + }, + listOfButtons = listOfButtons, + modifier = Modifier.padding(bottom = screenHeight * 0.01f), + screenWidth = screenWidth, + screenHeight = screenHeight) + } ProfileResults( profiles = filteredWorkerProfiles, modifier = Modifier.fillMaxWidth().weight(1f), searchViewModel = searchViewModel, accountViewModel = accountViewModel, listState = listState, - onBookClick = { selectedProfile -> + onBookClick = { selectedProfile, _ -> isWindowVisible = true selectedWorkerProfile = selectedProfile }, - baseLocation = baseLocation) + ) } } From 5e5ba7098c24a4076a6be65112ea258d25cc6fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Thu, 19 Dec 2024 17:57:26 +0100 Subject: [PATCH 12/15] Fix: all tests fixed and passing --- .../search/QuickFixSlidingWindowWorkerTest.kt | 86 ++--- .../ui/search/SearchOnBoardingTest.kt | 20 +- .../ui/search/SearchWorkerResultScreenTest.kt | 32 +- .../userModeUI/search/ProfileResults.kt | 103 +++--- .../userModeUI/search/QuickFixFinder.kt | 26 +- .../search/QuickFixSlidingWindowWorker.kt | 348 ++++++++++++------ .../userModeUI/search/SearchOnBoarding.kt | 74 ++-- .../userModeUI/search/SearchWorkerResult.kt | 110 +++--- 8 files changed, 484 insertions(+), 315 deletions(-) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt index 8412b5c4..0a037583 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt @@ -4,13 +4,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.printToLog import androidx.compose.ui.unit.dp -import com.arygm.quickfix.model.profile.WorkerProfile -import com.arygm.quickfix.model.profile.dataFields.AddOnService -import com.arygm.quickfix.model.profile.dataFields.IncludedService -import com.arygm.quickfix.model.profile.dataFields.Review +import com.arygm.quickfix.R import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.QuickFixSlidingWindowWorker import org.junit.Rule import org.junit.Test @@ -24,39 +19,38 @@ class QuickFixSlidingWindowWorkerTest { // Mock data val includedServices = listOf( - "Initial Consultation", - "Basic Surface Preparation", - "Priming of Surfaces", - "High-Quality Paint Application", - "Two Coats of Paint", - "Professional Cleanup") - .map { IncludedService(it) } + "Initial Consultation", + "Basic Surface Preparation", + "Priming of Surfaces", + "High-Quality Paint Application", + "Two Coats of Paint", + "Professional Cleanup") val addonServices = listOf( - "Detailed Color Consultation", - "Premium Paint Upgrade", - "Extensive Surface Preparation") - .map { AddOnService(it) } + "Detailed Color Consultation", "Premium Paint Upgrade", "Extensive Surface Preparation") val reviews = - ArrayDeque( - listOf("Great service!", "Very professional and clean.", "Would highly recommend.") - .map { Review("bob", it, 4.0) }) + listOf("Great service!", "Very professional and clean.", "Would highly recommend.") composeTestRule.setContent { QuickFixSlidingWindowWorker( isVisible = true, onDismiss = { /* No-op */}, + bannerImage = R.drawable.moroccan_flag, + profilePicture = R.drawable.placeholder_worker, + initialSaved = false, + workerCategory = "Painter", + workerAddress = "123 Main Street", + description = "Sample description for the worker.", + includedServices = includedServices, + addonServices = addonServices, + workerRating = 4.5, + tags = listOf("Exterior Painting", "Interior Painting"), + reviews = reviews, screenHeight = 800.dp, screenWidth = 400.dp, - onContinueClick = { /* No-op */}, - workerProfile = - WorkerProfile( - includedServices = includedServices, - addOnServices = addonServices, - reviews = reviews), - ) + onContinueClick = { /* No-op */}) } // Verify the included services section is displayed @@ -66,9 +60,8 @@ class QuickFixSlidingWindowWorkerTest { .assertIsDisplayed() // Check each included service - composeTestRule.onRoot().printToLog("includedServices") includedServices.forEach { service -> - composeTestRule.onNodeWithText("• ${service.name}").assertExists().assertIsDisplayed() + composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() } } @@ -99,11 +92,17 @@ class QuickFixSlidingWindowWorkerTest { QuickFixSlidingWindowWorker( isVisible = true, onDismiss = { /* No-op */}, - workerProfile = - WorkerProfile( - includedServices = includedServices.map { IncludedService(it) }, - addOnServices = addOnServices.map { AddOnService(it) }, - reviews = ArrayDeque(reviews.map { Review("bob", it, 4.0) })), + bannerImage = R.drawable.moroccan_flag, + profilePicture = R.drawable.placeholder_worker, + initialSaved = false, + workerCategory = "Painter", + workerAddress = "123 Main Street", + description = "Sample description for the worker.", + includedServices = includedServices, + addonServices = addOnServices, + workerRating = 4.5, + tags = listOf("Exterior Painting", "Interior Painting"), + reviews = reviews, screenHeight = 800.dp, screenWidth = 400.dp, onContinueClick = { /* No-op */}) @@ -117,7 +116,7 @@ class QuickFixSlidingWindowWorkerTest { // Check each add-on service addOnServices.forEach { service -> - composeTestRule.onNodeWithText("• ${service}").assertExists().assertIsDisplayed() + composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() } } @@ -156,12 +155,17 @@ class QuickFixSlidingWindowWorkerTest { QuickFixSlidingWindowWorker( isVisible = true, onDismiss = { /* No-op */}, - workerProfile = - WorkerProfile( - includedServices = includedServices.map { IncludedService(it) }, - addOnServices = addOnServices.map { AddOnService(it) }, - tags = tags, - reviews = ArrayDeque(reviews.map { Review("bob", it, 4.0) })), + bannerImage = R.drawable.moroccan_flag, + profilePicture = R.drawable.placeholder_worker, + initialSaved = false, + workerCategory = "Painter", + workerAddress = "123 Main Street", + description = "Sample description for the worker.", + includedServices = includedServices, + addonServices = addOnServices, + workerRating = 4.5, + tags = tags, + reviews = reviews, screenHeight = 800.dp, screenWidth = 400.dp, onContinueClick = { /* No-op */}) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt index c41beaca..9447fe96 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt @@ -79,7 +79,7 @@ class SearchOnBoardingTest { searchViewModel, accountViewModel, categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } @@ -100,7 +100,7 @@ class SearchOnBoardingTest { searchViewModel, accountViewModel, categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } @@ -130,7 +130,7 @@ class SearchOnBoardingTest { searchViewModel, accountViewModel, categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } @@ -151,7 +151,7 @@ class SearchOnBoardingTest { searchViewModel, accountViewModel, categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } @@ -196,7 +196,7 @@ class SearchOnBoardingTest { searchViewModel = searchViewModel, accountViewModel = accountViewModel, categoryViewModel = categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } @@ -271,7 +271,7 @@ class SearchOnBoardingTest { searchViewModel = searchViewModel, accountViewModel = accountViewModel, categoryViewModel = categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } @@ -309,7 +309,7 @@ class SearchOnBoardingTest { searchViewModel = searchViewModel, accountViewModel = accountViewModel, categoryViewModel = categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } @@ -335,7 +335,7 @@ class SearchOnBoardingTest { searchViewModel, accountViewModel, categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } @@ -359,7 +359,7 @@ class SearchOnBoardingTest { searchViewModel, accountViewModel, categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } @@ -387,7 +387,7 @@ class SearchOnBoardingTest { searchViewModel, accountViewModel, categoryViewModel, - onProfileClick = { _ -> }, + onProfileClick = { _, _ -> }, ) } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt index 10f190ec..96c8123d 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt @@ -13,12 +13,12 @@ import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.filter -import androidx.compose.ui.test.hasAnyChild import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasParent import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText @@ -29,6 +29,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.printToLog import androidx.core.app.ActivityCompat import androidx.datastore.preferences.core.Preferences import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -134,7 +135,8 @@ class SearchWorkerResultScreenTest { fieldOfWork = "Carpentry", rating = 3.0, description = "I hate my job", - location = Location(40.7128, -74.0060))) + location = Location(40.7128, -74.0060, "Ecublens, VD"), + )) // Mock the getAccountById method to always return a test Account doAnswer { invocation -> @@ -424,14 +426,14 @@ class SearchWorkerResultScreenTest { .onNodeWithTag("sliding_window_worker_category") .assertExists() .assertIsDisplayed() - .assertTextContains("Exterior Painter") // Replace with expected category + .assertTextContains("Carpentry") // Replace with expected category // Verify the worker address is displayed composeTestRule .onNodeWithTag("sliding_window_worker_address") .assertExists() .assertIsDisplayed() - .assertTextContains("Ecublens, VD") // Replace with expected address + .assertTextContains("New York") // Replace with expected address } @Test @@ -462,7 +464,7 @@ class SearchWorkerResultScreenTest { .assertExists() .assertIsDisplayed() // Check for each included service - val includedServices = listOf("Painting") + val includedServices = listOf("Basic Consultation", "Service Inspection") includedServices.forEach { service -> composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() @@ -498,7 +500,7 @@ class SearchWorkerResultScreenTest { .assertIsDisplayed() // Check for each add-on service - val addOnServices = listOf("Window Cleaning", "Furniture Assembly") + val addOnServices = listOf("Express Delivery", "Premium Materials") addOnServices.forEach { service -> composeTestRule.onNodeWithText("• $service").assertExists().assertIsDisplayed() @@ -567,9 +569,8 @@ class SearchWorkerResultScreenTest { // Verify the tags section is displayed composeTestRule.onNodeWithTag("sliding_window_tags_flow_row").assertExists().assertIsDisplayed() - // Check for each tag - val tags = listOf("Painter", "Gardener") + val tags = listOf("Reliable", "Experienced", "Professional") tags.forEach { tag -> composeTestRule.onNodeWithText(tag).assertExists().assertIsDisplayed() } } @@ -1350,7 +1351,12 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() // Select "Home" location - composeTestRule.onNodeWithTag("locationOptionRow1").performClick() + val roots = composeTestRule.onAllNodes(isRoot()) + roots.fetchSemanticsNodes().forEachIndexed { index, _ -> + println("Root Node $index Hierarchy:") + composeTestRule.onAllNodes(isRoot())[index].printToLog("RootNode$index") + } + composeTestRule.onNodeWithText("Home").performClick() // Click Apply composeTestRule.onNodeWithTag("applyButton").performClick() @@ -2032,7 +2038,7 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) composeTestRule.onNodeWithTag("tuneButton").performClick() - composeTestRule.onNodeWithTag("lazy_filter_row").performScrollToIndex(5) + composeTestRule.onNodeWithTag("filter_buttons_row").performScrollToIndex(5) // Click on the "Price Range" filter button composeTestRule.onNodeWithText("Emergency").performClick() @@ -2046,8 +2052,7 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(sortedWorkers.size) sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert( - hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) + workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) } composeTestRule.onNodeWithText("Emergency").performClick() @@ -2059,8 +2064,7 @@ class SearchWorkerResultScreenTest { workerNodes1.assertCountEquals(sortedWorkers.size) sortedWorkers1.forEachIndexed { index, worker -> - workerNodes[index].assert( - hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) + workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) } } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt index e9308a11..4b47f9e1 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt @@ -40,61 +40,64 @@ fun ProfileResults( ?: addresses?.firstOrNull()?.adminArea } - LazyColumn(modifier = modifier.fillMaxWidth(), state = listState) { - items(profiles.size) { index -> - val profile = profiles[index] - var account by remember { mutableStateOf(null) } - var distance by remember { mutableStateOf(null) } + LazyColumn( + modifier = modifier.fillMaxWidth().testTag("worker_profiles_list"), state = listState) { + items(profiles.size) { index -> + val profile = profiles[index] + var account by remember { mutableStateOf(null) } + var distance by remember { mutableStateOf(null) } - // Get user's current location and calculate distance - val locationHelper = LocationHelper(LocalContext.current, MainActivity()) - locationHelper.getCurrentLocation { location -> - location?.let { - distance = - profile.location?.let { workerLocation -> - searchViewModel - .calculateDistance( - workerLocation.latitude, - workerLocation.longitude, - it.latitude, - it.longitude) - .toInt() - } - } - } + // Get user's current location and calculate distance + val locationHelper = LocationHelper(LocalContext.current, MainActivity()) + locationHelper.getCurrentLocation { location -> + location?.let { + distance = + profile.location?.let { workerLocation -> + searchViewModel + .calculateDistance( + workerLocation.latitude, + workerLocation.longitude, + it.latitude, + it.longitude) + .toInt() + } + } + } - // Fetch user account details - LaunchedEffect(profile.uid) { - accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> - account = fetchedAccount - } - } + // Fetch user account details + LaunchedEffect(profile.uid) { + accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> + account = fetchedAccount + } + } - // Render profile card if account data is available - account?.let { acc -> - var cityName by remember { mutableStateOf(null) } - profile.location.let { - cityName = - profile.location?.let { it1 -> - getCityNameFromCoordinates(it1.latitude, profile.location.longitude) + // Render profile card if account data is available + account?.let { acc -> + var cityName by remember { mutableStateOf(null) } + profile.location.let { + cityName = + profile.location?.let { it1 -> + getCityNameFromCoordinates(it1.latitude, profile.location.longitude) + } + val displayLoc = if (cityName != null) cityName else "Unknown" + if (displayLoc != null) { + SearchWorkerProfileResult( + modifier = + Modifier.fillMaxWidth() + .testTag("worker_profile_result_$index") + .clickable {}, + profileImage = R.drawable.placeholder_worker, + name = "${acc.firstName} ${acc.lastName}", + category = profile.fieldOfWork, + rating = profile.rating, + reviewCount = profile.reviews.size, + location = displayLoc, + price = profile.price.roundToInt().toString(), + distance = distance, + onBookClick = { onBookClick(profile, displayLoc) }) } - val displayLoc = if (cityName != null) cityName else "Unknown" - if (displayLoc != null) { - SearchWorkerProfileResult( - modifier = - Modifier.fillMaxWidth().testTag("worker_profile_result_$index").clickable {}, - profileImage = R.drawable.placeholder_worker, - name = "${acc.firstName} ${acc.lastName}", - category = profile.fieldOfWork, - rating = profile.rating, - reviewCount = profile.reviews.size, - location = displayLoc, - price = profile.price.roundToInt().toString(), - distance = distance, - onBookClick = { onBookClick(profile, displayLoc) }) + } } } } - } - } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt index ab9a2e0f..9062668a 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel import com.arygm.quickfix.model.offline.small.PreferencesViewModel @@ -69,6 +70,10 @@ fun QuickFixFinderScreen( val colorBackground = if (pagerState.currentPage == 0) colorScheme.background else colorScheme.surface val colorButton = if (pagerState.currentPage == 1) colorScheme.background else colorScheme.surface + var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } + var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } + var initialSaved by remember { mutableStateOf(false) } + var workerAddress by remember { mutableStateOf("") } BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val screenHeight = maxHeight @@ -133,7 +138,14 @@ fun QuickFixFinderScreen( searchViewModel, accountViewModel, categoryViewModel, - onProfileClick = { profile -> selectedWorker = profile }) + onProfileClick = { profile, locName -> + selectedWorker = profile + bannerImage = R.drawable.moroccan_flag + profilePicture = R.drawable.placeholder_worker + initialSaved = false + workerAddress = locName + isWindowVisible = true + }) } 1 -> { AnnouncementScreen( @@ -160,7 +172,17 @@ fun QuickFixFinderScreen( quickFixViewModel.setSelectedWorkerProfile(selectedWorker) navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) }, - workerProfile = selectedWorker, + bannerImage = bannerImage, + profilePicture = profilePicture, + initialSaved = initialSaved, + workerCategory = selectedWorker.fieldOfWork, + workerAddress = workerAddress, + description = selectedWorker.description, + includedServices = selectedWorker.includedServices.map { it.name }, + addonServices = selectedWorker.addOnServices.map { it.name }, + workerRating = selectedWorker.reviews.map { it1 -> it1.rating }.average(), + tags = selectedWorker.tags, + reviews = selectedWorker.reviews.map { it.review }, ) } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt index 9d3012e8..e9f2a7e3 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt @@ -18,6 +18,8 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -25,6 +27,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.outlined.BookmarkBorder +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Text @@ -41,8 +44,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.arygm.quickfix.R -import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.ui.elements.QuickFixButton import com.arygm.quickfix.ui.elements.QuickFixSlidingWindow import com.arygm.quickfix.ui.elements.RatingBar @@ -52,69 +53,96 @@ import com.arygm.quickfix.ui.elements.RatingBar fun QuickFixSlidingWindowWorker( isVisible: Boolean, onDismiss: () -> Unit, + bannerImage: Int, + profilePicture: Int, + initialSaved: Boolean, + workerCategory: String, + workerAddress: String, + description: String, + includedServices: List, + addonServices: List, + workerRating: Double, + tags: List, + reviews: List, screenHeight: Dp, screenWidth: Dp, - onContinueClick: () -> Unit, - workerProfile: WorkerProfile + onContinueClick: () -> Unit ) { - var saved by remember { mutableStateOf(false) } + var saved by remember { mutableStateOf(initialSaved) } var showFullDescription by remember { mutableStateOf(false) } QuickFixSlidingWindow(isVisible = isVisible, onDismiss = onDismiss) { + // Content of the sliding window Column( modifier = Modifier.clip(RoundedCornerShape(topStart = 25f, bottomStart = 25f)) .fillMaxWidth() .background(colorScheme.background) .testTag("sliding_window_content")) { - // Top Bar with Banner Image and Profile Picture - Box(modifier = Modifier.fillMaxWidth().height(screenHeight * 0.23f)) { - Image( - painter = painterResource(id = R.drawable.placeholder_worker), // Default fallback - contentDescription = "Banner", - modifier = Modifier.fillMaxWidth().height(screenHeight * 0.2f), - contentScale = ContentScale.Crop) - QuickFixButton( - buttonText = if (saved) "saved" else "save", - onClickAction = { saved = !saved }, - buttonColor = colorScheme.surface, - textColor = colorScheme.onBackground, - textStyle = MaterialTheme.typography.labelMedium, - contentPadding = PaddingValues(horizontal = screenWidth * 0.01f), - modifier = - Modifier.align(Alignment.BottomEnd) - .width(screenWidth * 0.25f) - .offset(x = -(screenWidth * 0.04f)), - leadingIcon = if (saved) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder) - - Image( - painter = - painterResource(id = R.drawable.placeholder_worker), // Fallback for profile - contentDescription = "Profile Picture", - modifier = - Modifier.size(screenHeight * 0.1f) - .align(Alignment.BottomStart) - .offset(x = screenWidth * 0.04f) - .clip(CircleShape), - contentScale = ContentScale.Crop) - } - - // Worker Information - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.04f)) { - Text( - text = workerProfile.fieldOfWork, - style = MaterialTheme.typography.headlineLarge, - color = colorScheme.onBackground, - modifier = Modifier.testTag("sliding_window_worker_category")) - - Text( - text = workerProfile.location?.name ?: "Unknown Location", - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onBackground, - modifier = Modifier.testTag("sliding_window_worker_address")) - } - - // Scrollable Content + + // Top Bar + Box( + modifier = + Modifier.fillMaxWidth() + .height(screenHeight * 0.23f) + .testTag("sliding_window_top_bar")) { + // Banner Image + Image( + painter = painterResource(id = bannerImage), + contentDescription = "Banner", + modifier = + Modifier.fillMaxWidth() + .height(screenHeight * 0.2f) + .testTag("sliding_window_banner_image"), + contentScale = ContentScale.Crop) + + QuickFixButton( + buttonText = if (saved) "saved" else "save", + onClickAction = { saved = !saved }, + buttonColor = colorScheme.surface, + textColor = colorScheme.onBackground, + textStyle = MaterialTheme.typography.labelMedium, + contentPadding = PaddingValues(horizontal = screenWidth * 0.01f), + modifier = + Modifier.align(Alignment.BottomEnd) + .width(screenWidth * 0.25f) + .offset(x = -(screenWidth * 0.04f)) + .testTag("sliding_window_save_button"), + leadingIcon = + if (saved) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder) + + // Profile picture overlapping the banner image + Image( + painter = painterResource(id = profilePicture), + contentDescription = "Profile Picture", + modifier = + Modifier.size(screenHeight * 0.1f) + .align(Alignment.BottomStart) + .offset(x = screenWidth * 0.04f) + .clip(CircleShape) + .testTag("sliding_window_profile_picture"), + contentScale = ContentScale.Crop) + } + + // Worker Field and Address under the profile picture + Column( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_worker_additional_info")) { + Text( + text = workerCategory, + style = MaterialTheme.typography.headlineLarge, + color = colorScheme.onBackground, + modifier = Modifier.testTag("sliding_window_worker_category")) + Text( + text = workerAddress, + style = MaterialTheme.typography.headlineSmall, + color = colorScheme.onBackground, + modifier = Modifier.testTag("sliding_window_worker_address")) + } + + // Main content should be scrollable Column( modifier = Modifier.fillMaxWidth() @@ -123,120 +151,216 @@ fun QuickFixSlidingWindowWorker( .testTag("sliding_window_scrollable_content")) { Spacer(modifier = Modifier.height(screenHeight * 0.02f)) - // Description + // Description with "Show more" functionality val descriptionText = - if (showFullDescription || workerProfile.description.length <= 100) { - workerProfile.description + if (showFullDescription || description.length <= 100) { + description } else { - workerProfile.description.take(100) + "..." + description.take(100) + "..." } + Text( text = descriptionText, style = MaterialTheme.typography.bodySmall, color = colorScheme.onSurface, - modifier = Modifier.padding(horizontal = screenWidth * 0.04f)) + modifier = + Modifier.padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_description")) - if (workerProfile.description.length > 100) { + if (description.length > 100) { Text( text = if (showFullDescription) "Show less" else "Show more", style = MaterialTheme.typography.bodySmall.copy(color = colorScheme.primary), modifier = - Modifier.padding(horizontal = screenWidth * 0.04f).clickable { - showFullDescription = !showFullDescription - }) + Modifier.padding(horizontal = screenWidth * 0.04f) + .clickable { showFullDescription = !showFullDescription } + .testTag("sliding_window_description_show_more_button")) } + // Delimiter between description and services + Spacer(modifier = Modifier.height(screenHeight * 0.02f)) + + HorizontalDivider( + modifier = + Modifier.padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_horizontal_divider_1"), + thickness = 1.dp, + color = colorScheme.onSurface.copy(alpha = 0.2f)) Spacer(modifier = Modifier.height(screenHeight * 0.02f)) // Services Section - Row(modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.04f)) { - Column( - modifier = - Modifier.weight(1f).testTag("sliding_window_included_services_column")) { - Text( - text = "Included Services", - style = MaterialTheme.typography.headlineMedium, - color = colorScheme.onBackground) - workerProfile.includedServices.forEach { service -> - Text( - text = "• ${service.name}", - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface) - } - } + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_services_row")) { + // Included Services + Column( + modifier = + Modifier.weight(1f) + .testTag("sliding_window_included_services_column")) { + Text( + text = "Included Services", + style = MaterialTheme.typography.headlineMedium, + color = colorScheme.onBackground) + Spacer(modifier = Modifier.height(screenHeight * 0.01f)) + includedServices.forEach { service -> + Text( + text = "• $service", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface, + modifier = Modifier.padding(bottom = screenHeight * 0.005f)) + } + } - Spacer(modifier = Modifier.width(screenWidth * 0.02f)) + Spacer(modifier = Modifier.width(screenWidth * 0.02f)) - Column( - modifier = - Modifier.weight(1f).testTag("sliding_window_addon_services_column")) { - Text( - text = "Add-On Services", - style = MaterialTheme.typography.headlineMedium, - color = colorScheme.primary) - workerProfile.addOnServices.forEach { service -> - Text( - text = "• ${service.name}", - style = MaterialTheme.typography.bodySmall, - color = colorScheme.primary) - } - } - } + // Add-On Services + Column( + modifier = + Modifier.weight(1f).testTag("sliding_window_addon_services_column")) { + Text( + text = "Add-On Services", + style = MaterialTheme.typography.headlineMedium, + color = colorScheme.primary) + Spacer(modifier = Modifier.height(screenHeight * 0.01f)) + addonServices.forEach { service -> + Text( + text = "• $service", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.primary, + modifier = Modifier.padding(bottom = screenHeight * 0.005f)) + } + } + } Spacer(modifier = Modifier.height(screenHeight * 0.03f)) - // Continue Button + // Continue Button with Rate/HR QuickFixButton( buttonText = "Continue", onClickAction = onContinueClick, buttonColor = colorScheme.primary, textColor = colorScheme.onPrimary, textStyle = MaterialTheme.typography.labelMedium, - modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.04f)) + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_continue_button")) + + Spacer(modifier = Modifier.height(screenHeight * 0.02f)) + HorizontalDivider( + modifier = + Modifier.padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_horizontal_divider_2"), + thickness = 1.dp, + color = colorScheme.onSurface.copy(alpha = 0.2f), + ) Spacer(modifier = Modifier.height(screenHeight * 0.02f)) - // Tags + // Tags Section Text( text = "Tags", style = MaterialTheme.typography.headlineMedium, color = colorScheme.onBackground, modifier = Modifier.padding(horizontal = screenWidth * 0.04f)) + Spacer(modifier = Modifier.height(screenHeight * 0.01f)) + // Display tags using FlowRow for wrapping FlowRow( horizontalArrangement = Arrangement.spacedBy(screenWidth * 0.02f), verticalArrangement = Arrangement.spacedBy(screenHeight * 0.01f), modifier = Modifier.fillMaxWidth() .padding(horizontal = screenWidth * 0.04f) - .testTag("sliding_window_tags_flow_row")) { - workerProfile.tags.forEach { tag -> - Text( - text = tag, - color = colorScheme.primary, - style = MaterialTheme.typography.bodySmall, - modifier = - Modifier.border( - 1.dp, colorScheme.primary, MaterialTheme.shapes.small) - .padding(horizontal = 8.dp, vertical = 4.dp)) - } - } + .testTag("sliding_window_tags_flow_row"), + ) { + tags.forEach { tag -> + Text( + text = tag, + color = colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier.border( + width = 1.dp, + color = colorScheme.primary, + shape = MaterialTheme.shapes.small) + .padding( + horizontal = screenWidth * 0.02f, + vertical = screenHeight * 0.005f)) + } + } + + Spacer(modifier = Modifier.height(screenHeight * 0.02f)) + HorizontalDivider( + modifier = + Modifier.padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_horizontal_divider_3"), + thickness = 1.dp, + color = colorScheme.onSurface.copy(alpha = 0.2f)) Spacer(modifier = Modifier.height(screenHeight * 0.02f)) - // Reviews Text( text = "Reviews", style = MaterialTheme.typography.headlineMedium, color = colorScheme.onBackground, modifier = Modifier.padding(horizontal = screenWidth * 0.04f)) + Spacer(modifier = Modifier.height(screenHeight * 0.01f)) + + // Star Rating Row Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = screenWidth * 0.04f)) { + modifier = + Modifier.padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_star_rating_row")) { RatingBar( - workerProfile.rating.toFloat(), - modifier = Modifier.height(screenHeight * 0.03f)) + workerRating.toFloat(), + modifier = Modifier.height(screenHeight * 0.03f).testTag("starsRow")) } + Spacer(modifier = Modifier.height(screenHeight * 0.01f)) + LazyRow( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = screenWidth * 0.04f) + .testTag("sliding_window_reviews_row")) { + itemsIndexed(reviews) { index, review -> + var isExpanded by remember { mutableStateOf(false) } + val displayText = + if (isExpanded || review.length <= 100) { + review + } else { + review.take(100) + "..." + } + + Box( + modifier = + Modifier.padding(end = screenWidth * 0.02f) + .width(screenWidth * 0.6f) + .clip(RoundedCornerShape(25f)) + .background(colorScheme.background)) { + Column(modifier = Modifier.padding(screenWidth * 0.02f)) { + Text( + text = displayText, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface) + if (review.length > 100) { + Text( + text = if (isExpanded) "See less" else "See more", + style = + MaterialTheme.typography.bodySmall.copy( + color = colorScheme.primary), + modifier = + Modifier.clickable { isExpanded = !isExpanded } + .padding(top = screenHeight * 0.01f)) + } + } + } + } + } + + Spacer(modifier = Modifier.height(screenHeight * 0.02f)) } } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index b7e532f9..7783a366 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -57,15 +57,15 @@ fun SearchOnBoarding( searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, categoryViewModel: CategoryViewModel, - onProfileClick: (WorkerProfile) -> Unit, + onProfileClick: (WorkerProfile, String) -> Unit, ) { val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } val workerProfiles by searchViewModel.workerProfilesSuggestions.collectAsState() var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } val context = LocalContext.current val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() - var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") var locationFilterApplied by remember { mutableStateOf(false) } + var userProfile by remember { mutableStateOf(null) } var lastAppliedMaxDist by remember { mutableIntStateOf(200) } val focusManager = LocalFocusManager.current var selectedLocation by remember { mutableStateOf(Location()) } @@ -205,7 +205,7 @@ fun SearchOnBoarding( searchViewModel = searchViewModel, accountViewModel = accountViewModel, listState = listState, - onBookClick = { selectedProfile, _ -> onProfileClick(selectedProfile) }) + onBookClick = { selectedProfile, loc -> onProfileClick(selectedProfile, loc) }) } }, modifier = @@ -268,40 +268,42 @@ fun SearchOnBoarding( }, clearEnabled = filterState.priceFilterApplied) - QuickFixLocationFilterBottomSheet( - uiState.showLocationBottomSheet, - userProfile = userProfile, - phoneLocation = filterState.phoneLocation, - selectedLocationIndex = selectedLocationIndex, - onApplyClick = { location, max -> - selectedLocation = location - lastAppliedMaxDist = max - baseLocation = location - maxDistance = max - selectedLocationIndex = userProfile.locations.indexOf(location) + 1 + userProfile?.let { + QuickFixLocationFilterBottomSheet( + uiState.showLocationBottomSheet, + userProfile = it, + phoneLocation = filterState.phoneLocation, + selectedLocationIndex = selectedLocationIndex, + onApplyClick = { location, max -> + selectedLocation = location + lastAppliedMaxDist = max + baseLocation = location + maxDistance = max + selectedLocationIndex = it.locations.indexOf(location) + 1 - if (location == Location(0.0, 0.0, "Default")) { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() - } - if (locationFilterApplied) { + if (location == Location(0.0, 0.0, "Default")) { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } + if (locationFilterApplied) { + updateFilteredProfiles() + } else { + filteredWorkerProfiles = + searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) + } + locationFilterApplied = true + }, + onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, + onClearClick = { + baseLocation = filterState.phoneLocation + lastAppliedMaxDist = 200 + selectedLocation = Location() + maxDistance = 0 + selectedLocationIndex = null + locationFilterApplied = false updateFilteredProfiles() - } else { - filteredWorkerProfiles = - searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) - } - locationFilterApplied = true - }, - onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, - onClearClick = { - baseLocation = filterState.phoneLocation - lastAppliedMaxDist = 200 - selectedLocation = Location() - maxDistance = 0 - selectedLocationIndex = null - locationFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = locationFilterApplied, - end = lastAppliedMaxDist) + }, + clearEnabled = locationFilterApplied, + end = lastAppliedMaxDist) + } } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index 4a536ced..4b77bbe6 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -1,6 +1,5 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search -import android.util.Log import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -34,11 +33,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp import com.arygm.quickfix.MainActivity +import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.offline.small.PreferencesViewModel @@ -74,21 +73,11 @@ fun SearchWorkerResult( var selectedWorkerProfile by remember { mutableStateOf(WorkerProfile()) } val filterState = rememberSearchFiltersState() var baseLocation by remember { mutableStateOf(filterState.phoneLocation) } - var userProfile = UserProfile(locations = emptyList(), announcements = emptyList(), uid = "0") + var userProfile by remember { mutableStateOf(null) } var uid by remember { mutableStateOf("Loading...") } val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() // Fetch user and set base location - LaunchedEffect(Unit) { - uid = loadUserId(preferencesViewModel) - userProfileViewModel.fetchUserProfile(uid) { profile -> - if (profile is UserProfile) { - userProfile = profile - } else { - Log.e("SearchWorkerResult", "Fetched a worker profile from a user profile repo.") - } - } - } LaunchedEffect(Unit) { if (locationHelper.checkPermissions()) { @@ -103,6 +92,8 @@ fun SearchWorkerResult( } else { Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() } + uid = loadUserId(preferencesViewModel) + userProfileViewModel.fetchUserProfile(uid) { profile -> userProfile = profile as UserProfile } } val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() @@ -113,6 +104,10 @@ fun SearchWorkerResult( var selectedLocation by remember { mutableStateOf(Location()) } var maxDistance by remember { mutableIntStateOf(0) } var selectedLocationIndex by remember { mutableStateOf(null) } + var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } + var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } + var initialSaved by remember { mutableStateOf(false) } + var workerAddress by remember { mutableStateOf("") } var lastAppliedMaxDist by remember { mutableIntStateOf(200) } @@ -195,8 +190,7 @@ fun SearchWorkerResult( Modifier.fillMaxWidth() .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) .padding(horizontal = screenWidth * 0.02f) - .wrapContentHeight() - .testTag("filter_buttons_row"), + .wrapContentHeight(), verticalAlignment = Alignment.CenterVertically, ) { FilterRow( @@ -215,7 +209,11 @@ fun SearchWorkerResult( searchViewModel = searchViewModel, accountViewModel = accountViewModel, listState = listState, - onBookClick = { selectedProfile, _ -> + onBookClick = { selectedProfile, locName -> + bannerImage = R.drawable.moroccan_flag + profilePicture = R.drawable.placeholder_worker + initialSaved = false + workerAddress = locName isWindowVisible = true selectedWorkerProfile = selectedProfile }, @@ -279,41 +277,43 @@ fun SearchWorkerResult( }, clearEnabled = filterState.priceFilterApplied) - QuickFixLocationFilterBottomSheet( - uiState.showLocationBottomSheet, - userProfile = userProfile, - phoneLocation = filterState.phoneLocation, - selectedLocationIndex = selectedLocationIndex, - onApplyClick = { location, max -> - selectedLocation = location - lastAppliedMaxDist = max - baseLocation = location - maxDistance = max - selectedLocationIndex = userProfile.locations.indexOf(location) + 1 + userProfile?.let { + QuickFixLocationFilterBottomSheet( + uiState.showLocationBottomSheet, + userProfile = it, + phoneLocation = filterState.phoneLocation, + selectedLocationIndex = selectedLocationIndex, + onApplyClick = { location, max -> + selectedLocation = location + lastAppliedMaxDist = max + baseLocation = location + maxDistance = max + selectedLocationIndex = it.locations.indexOf(location) + 1 - if (location == Location(0.0, 0.0, "Default")) { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() - } - if (locationFilterApplied) { + if (location == Location(0.0, 0.0, "Default")) { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } + if (locationFilterApplied) { + updateFilteredProfiles() + } else { + filteredWorkerProfiles = + searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) + } + locationFilterApplied = true + }, + onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, + onClearClick = { + baseLocation = filterState.phoneLocation + lastAppliedMaxDist = 200 + selectedLocation = Location() + maxDistance = 0 + selectedLocationIndex = null + locationFilterApplied = false updateFilteredProfiles() - } else { - filteredWorkerProfiles = - searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) - } - locationFilterApplied = true - }, - onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, - onClearClick = { - baseLocation = filterState.phoneLocation - lastAppliedMaxDist = 200 - selectedLocation = Location() - maxDistance = 0 - selectedLocationIndex = null - locationFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = locationFilterApplied, - end = lastAppliedMaxDist) + }, + clearEnabled = locationFilterApplied, + end = lastAppliedMaxDist) + } QuickFixSlidingWindowWorker( isVisible = isWindowVisible, onDismiss = { isWindowVisible = false }, @@ -323,7 +323,17 @@ fun SearchWorkerResult( quickFixViewModel.setSelectedWorkerProfile(selectedWorkerProfile) navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) }, - workerProfile = selectedWorkerProfile, + bannerImage = bannerImage, + profilePicture = profilePicture, + initialSaved = initialSaved, + workerCategory = selectedWorkerProfile.fieldOfWork, + workerAddress = workerAddress, + description = selectedWorkerProfile.description, + includedServices = selectedWorkerProfile.includedServices.map { it.name }, + addonServices = selectedWorkerProfile.addOnServices.map { it.name }, + workerRating = selectedWorkerProfile.reviews.map { it1 -> it1.rating }.average(), + tags = selectedWorkerProfile.tags, + reviews = selectedWorkerProfile.reviews.map { it.review }, ) } } From 4d07d5850943d19952e1165868e0a5ca7b3bffb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Fri, 20 Dec 2024 00:02:26 +0100 Subject: [PATCH 13/15] Fix: most tests passing --- app/build.gradle.kts | 11 +- .../quickfix/kaspresso/MainActivityTest.kt | 38 +- .../QuickFixLocationFilterBottomSheetTest.kt | 16 +- ...rofileConfigurationUserNoModeScreenTest.kt | 259 ++----- .../ui/search/AnnouncementCardTest.kt | 354 +++++++++ .../ui/search/AnnouncementDetailTest.kt | 2 +- .../ui/search/AnnouncementsScreenTest.kt | 353 +++++++++ .../quickfix/ui/search/ProfileResultsTest.kt | 26 + .../ui/search/QuickFixFinderScreenTest.kt | 18 +- .../ui/search/SearchOnBoardingTest.kt | 30 +- .../search/SearchWorkerProfileResultTest.kt | 8 +- .../ui/search/SearchWorkerResultScreenTest.kt | 174 +++-- .../ui/tools/ai/QuickFixAIChatScreenTest.kt | 84 ++ app/src/main/AndroidManifest.xml | 4 + .../arygm/quickfix/model/account/Account.kt | 2 + .../model/account/AccountRepository.kt | 9 + .../account/AccountRepositoryFirestore.kt | 49 +- .../model/account/AccountViewModel.kt | 17 +- .../offline/small/PreferencesViewModel.kt | 3 + .../arygm/quickfix/model/profile/Profile.kt | 60 +- .../model/profile/ProfileRepository.kt | 12 + .../model/profile/ProfileViewModel.kt | 17 +- .../profile/UserProfileRepositoryFirestore.kt | 111 +++ .../WorkerProfileRepositoryFirestore.kt | 200 +++-- .../model/search/AnnouncementRepository.kt | 6 + .../search/AnnouncementRepositoryFirestore.kt | 16 + .../model/search/AnnouncementViewModel.kt | 120 ++- .../quickfix/model/search/SearchViewModel.kt | 46 -- .../model/tools/ai/GeminiMessageModel.kt | 3 + .../model/tools/ai/GeminiViewModel.kt | 102 +++ .../ui/dashboard/AnnouncementsWidget.kt | 16 +- .../QuickFixLocationFilterBottomSheet.kt | 18 +- .../uiMode/appContentUI/AppContentNavGraph.kt | 2 + .../userModeUI/UserModeNavGraph.kt | 15 +- .../userModeUI/home/HomeScreen.kt | 26 +- .../userModeUI/navigation/UserNavigation.kt | 1 + .../profile/AccountConfiguration.kt | 558 ++++++++------ .../userModeUI/profile/UserProfileScreen.kt | 2 +- .../becomeWorker/UpgradeToWorkerScreen.kt | 8 +- .../search/ExpandableCategoryItem.kt | 1 - .../userModeUI/search/ProfileResults.kt | 118 +-- .../userModeUI/search/QuickFixFinder.kt | 105 ++- .../userModeUI/search/SearchOnBoarding.kt | 248 +++--- .../search/SearchWorkerProfileResult.kt | 17 +- .../userModeUI/search/SearchWorkerResult.kt | 582 ++++++++------ .../tools/ai/QuickFixAIChatScreen.kt | 153 ++++ .../workerMode/WorkerModeNavGraph.kt | 75 +- .../announcements/AnnouncementCard.kt | 220 ++++++ .../announcements/AnnouncementsScreen.kt | 293 ++++++- .../workerMode/navigation/WorkerNavigation.kt | 2 + .../com/arygm/quickfix/utils/Preferences.kt | 24 + .../account/AccountRepositoryFirestoreTest.kt | 128 +++- .../model/account/AccountViewModelTest.kt | 25 +- .../model/profile/SearchViewModelTest.kt | 222 ------ ...rofileRepositoryFirestoreImageFetchTest.kt | 339 +++++++++ .../UserProfileRepositoryFirestoreTest.kt | 178 +++++ ...rofileRepositoryFirestoreImageFetchTest.kt | 339 +++++++++ .../WorkerProfileRepositoryFirestoreTest.kt | 717 +----------------- .../AnnouncementRepositoryFirestoreTest.kt | 127 +++- .../model/search/AnnouncementViewModelTest.kt | 190 +++++ .../model/tools/ai/QuickFixViewModelTest.kt | 67 ++ end2end-data/auth_export/accounts.json | 2 +- end2end-data/firebase-export-metadata.json | 6 +- .../all_namespaces_all_kinds.export_metadata | Bin 52 -> 52 bytes .../all_namespaces/all_kinds/output-0 | Bin 8979 -> 10075 bytes .../firestore_export.overall_export_metadata | Bin 95 -> 95 bytes .../702c0558-bd86-438b-90df-670579ada7d3 | Bin 2455 -> 0 bytes .../702c0558-bd86-438b-90df-670579ada7d3.json | 19 - gradle/libs.versions.toml | 10 + populate_data/upload_workers.py | 2 +- 70 files changed, 4898 insertions(+), 2107 deletions(-) create mode 100644 app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementCardTest.kt create mode 100644 app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementsScreenTest.kt create mode 100644 app/src/androidTest/java/com/arygm/quickfix/ui/tools/ai/QuickFixAIChatScreenTest.kt create mode 100644 app/src/main/java/com/arygm/quickfix/model/tools/ai/GeminiMessageModel.kt create mode 100644 app/src/main/java/com/arygm/quickfix/model/tools/ai/GeminiViewModel.kt create mode 100644 app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/tools/ai/QuickFixAIChatScreen.kt create mode 100644 app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/announcements/AnnouncementCard.kt create mode 100644 app/src/test/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestoreImageFetchTest.kt create mode 100644 app/src/test/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestoreImageFetchTest.kt create mode 100644 app/src/test/java/com/arygm/quickfix/model/tools/ai/QuickFixViewModelTest.kt delete mode 100644 end2end-data/storage_export/blobs/702c0558-bd86-438b-90df-670579ada7d3 delete mode 100644 end2end-data/storage_export/metadata/702c0558-bd86-438b-90df-670579ada7d3.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0c288f9..f860a768 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,6 +33,7 @@ android { val mapsApiKey: String = localProperties.getProperty("MAPS_API_KEY") ?: "" val sonarToken: String = localProperties.getProperty("SONAR_TOKEN") ?: "" + val geminiApiKey: String = localProperties.getProperty("GEMINI_API_KEY") ?: "" defaultConfig { applicationId = "com.arygm.quickfix" @@ -47,6 +48,12 @@ android { } manifestPlaceholders["MAPS_API_KEY"] = mapsApiKey manifestPlaceholders["SONAR_TOKEN"] = sonarToken + manifestPlaceholders["GEMINI_API_KEY"] = geminiApiKey + + buildConfigField("String", "MAPS_API_KEY", "\"$mapsApiKey\"") + buildConfigField("String", "SONAR_TOKEN", "\"$sonarToken\"") + buildConfigField("String", "GEMINI_API_KEY", "\"$geminiApiKey\"") + } signingConfigs { @@ -181,10 +188,11 @@ dependencies { implementation(libs.okhttp) implementation(libs.kotlinx.serialization.json) implementation(libs.firebase.storage.ktx) + implementation(libs.firebase.appcheck.playintegrity) testImplementation(libs.json) implementation(libs.gson) - + implementation(libs.generativeai.v070) implementation(libs.androidx.core.ktx) implementation(files("libs/meow-bottom-navigation-java-1.2.0.aar")) @@ -203,6 +211,7 @@ dependencies { implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) testImplementation(libs.junit) + testImplementation(libs.androidx.core.testing) // or latest version globalTestImplementation(libs.androidx.junit) androidTestImplementation(libs.mockk) androidTestImplementation(libs.mockk.android) diff --git a/app/src/androidTest/java/com/arygm/quickfix/kaspresso/MainActivityTest.kt b/app/src/androidTest/java/com/arygm/quickfix/kaspresso/MainActivityTest.kt index 7803429b..92d9f246 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/kaspresso/MainActivityTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/kaspresso/MainActivityTest.kt @@ -104,6 +104,40 @@ class MainActivityTest : TestCase() { "Renovations and Additions", "Demolition and Removal", "Insulation Installation", + "Clean-Up")), + Subcategory( + id = "furniture_carpentry", + name = "Furniture Carpentry", + category = "Carpentry", + tags = + listOf( + "Custom Furniture", + "Restoration", + "Handcrafted Woodwork", + "Modern Designs", + "Wooden Art Pieces", + "Bespoke Joinery"), + scale = + Scale( + longScale = + "Prices are displayed relative to the cost of crafting a custom piece of furniture.", + shortScale = "Custom furniture equivalent"), + setServices = + listOf( + "Custom Furniture Design", + "Furniture Restoration", + "Cabinet Making", + "Shelving Units", + "Built-In Closets", + "Table and Chair Construction", + "Antique Repair", + "Wood Finishing and Staining", + "Upholstery Services", + "Furniture Assembly", + "Outdoor Furniture", + "Furniture Modification", + "Wood Carving", + "Veneer Work", "Clean-Up")))) fun allowPermissionsIfNeeded() { @@ -257,7 +291,7 @@ class MainActivityTest : TestCase() { // Select the first subcategory composeTestRule - .onNodeWithTag(C.Tag.professionalInfoScreenSubcategoryDropdownMenuItem + 0) + .onNodeWithTag(C.Tag.professionalInfoScreenSubcategoryDropdownMenuItem + 1) .performClick() composeTestRule.onNodeWithTag(C.Tag.professionalInfoScreenPriceField).performTextInput("100") @@ -366,7 +400,7 @@ class MainActivityTest : TestCase() { .isNotEmpty() } updateAccountConfigurationAndVerify( - composeTestRule, "Ramo", "Hatimo", "28/10/2004", "Ramo Hatimo", 2) + composeTestRule, "Rame", "Hatime", "28/10/2004", "Rame Hatime", 2) composeTestRule.waitUntil("find the switch", timeoutMillis = 20000) { composeTestRule.onAllNodesWithTag(C.Tag.buttonSwitch).fetchSemanticsNodes().isNotEmpty() } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/elements/QuickFixLocationFilterBottomSheetTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/elements/QuickFixLocationFilterBottomSheetTest.kt index 05427bc0..a450be99 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/elements/QuickFixLocationFilterBottomSheetTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/elements/QuickFixLocationFilterBottomSheetTest.kt @@ -46,7 +46,7 @@ class QuickFixLocationFilterBottomSheetTest { QuickFixTheme { QuickFixLocationFilterBottomSheet( showModalBottomSheet = true, - userProfile = userProfile, + profile = userProfile, phoneLocation = phoneLocation, onApplyClick = onApplyClick, onDismissRequest = onDismissRequest, @@ -64,7 +64,7 @@ class QuickFixLocationFilterBottomSheetTest { QuickFixTheme { QuickFixLocationFilterBottomSheet( showModalBottomSheet = false, - userProfile = userProfile, + profile = userProfile, phoneLocation = phoneLocation, onApplyClick = onApplyClick, onDismissRequest = onDismissRequest, @@ -82,7 +82,7 @@ class QuickFixLocationFilterBottomSheetTest { QuickFixTheme { QuickFixLocationFilterBottomSheet( showModalBottomSheet = true, - userProfile = userProfile, + profile = userProfile, phoneLocation = phoneLocation, onApplyClick = onApplyClick, onDismissRequest = onDismissRequest, @@ -113,7 +113,7 @@ class QuickFixLocationFilterBottomSheetTest { QuickFixTheme { QuickFixLocationFilterBottomSheet( showModalBottomSheet = true, - userProfile = userProfile, + profile = userProfile, phoneLocation = phoneLocation, onApplyClick = onApplyClick, onDismissRequest = onDismissRequest, @@ -147,7 +147,7 @@ class QuickFixLocationFilterBottomSheetTest { QuickFixTheme { QuickFixLocationFilterBottomSheet( showModalBottomSheet = true, - userProfile = userProfile, + profile = userProfile, phoneLocation = phoneLocation, onApplyClick = { loc, r -> appliedLocation = loc @@ -183,7 +183,7 @@ class QuickFixLocationFilterBottomSheetTest { QuickFixTheme { QuickFixLocationFilterBottomSheet( showModalBottomSheet = true, - userProfile = userProfile, + profile = userProfile, phoneLocation = phoneLocation, onApplyClick = onApplyClick, onDismissRequest = onDismissRequest, @@ -206,7 +206,7 @@ class QuickFixLocationFilterBottomSheetTest { QuickFixTheme { QuickFixLocationFilterBottomSheet( showModalBottomSheet = true, - userProfile = userProfile, + profile = userProfile, phoneLocation = phoneLocation, onApplyClick = onApplyClick, onDismissRequest = { dismissCalled = true }, @@ -238,7 +238,7 @@ class QuickFixLocationFilterBottomSheetTest { QuickFixTheme { QuickFixLocationFilterBottomSheet( showModalBottomSheet = true, - userProfile = userProfile, + profile = userProfile, phoneLocation = phoneLocation, onApplyClick = onApplyClick, onDismissRequest = { dismissCalled = true }, diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/profile/ProfileConfigurationUserNoModeScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/profile/ProfileConfigurationUserNoModeScreenTest.kt index 1e42e8f7..ef7cf8d3 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/profile/ProfileConfigurationUserNoModeScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/profile/ProfileConfigurationUserNoModeScreenTest.kt @@ -1,5 +1,6 @@ package com.arygm.quickfix.ui.profile +import android.graphics.Bitmap import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule import androidx.datastore.preferences.core.Preferences @@ -8,15 +9,11 @@ import com.arygm.quickfix.model.account.AccountRepository import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.offline.small.PreferencesRepository import com.arygm.quickfix.model.offline.small.PreferencesViewModel -import com.arygm.quickfix.model.profile.* import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.QuickFixTheme import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.profile.AccountConfigurationScreen import com.arygm.quickfix.utils.IS_WORKER_KEY import com.google.firebase.Timestamp -import com.google.firebase.firestore.FirebaseFirestore -import java.util.Calendar -import java.util.GregorianCalendar import kotlinx.coroutines.flow.flowOf import org.junit.Assert.assertEquals import org.junit.Before @@ -26,7 +23,6 @@ import org.mockito.Mockito.* import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -35,11 +31,8 @@ class ProfileConfigurationUserNoModeScreenTest { @get:Rule val composeTestRule = createComposeRule() private lateinit var navigationActions: NavigationActions - private lateinit var mockFirestore: FirebaseFirestore private lateinit var accountRepository: AccountRepository private lateinit var accountViewModel: AccountViewModel - private lateinit var userProfileRepositoryFirestore: ProfileRepository - private lateinit var workerProfileRepositoryFirestore: ProfileRepository private lateinit var preferencesRepository: PreferencesRepository private lateinit var preferencesViewModel: PreferencesViewModel @@ -50,22 +43,19 @@ class ProfileConfigurationUserNoModeScreenTest { lastName = "Doe", birthDate = Timestamp.now(), email = "john.doe@example.com", - isWorker = false) + profilePicture = "https://example.com/profile.jpg") @Before fun setup() { - mockFirestore = mock() navigationActions = mock() accountRepository = mock() accountViewModel = AccountViewModel(accountRepository) preferencesRepository = mock() preferencesViewModel = PreferencesViewModel(preferencesRepository) - // Explicitly specify the type for `any()` + // Mock preferences repository to provide test data whenever(preferencesRepository.getPreferenceByKey(any>())) .thenReturn(flowOf("testValue")) - - // Mock specific keys explicitly whenever(preferencesRepository.getPreferenceByKey(com.arygm.quickfix.utils.UID_KEY)) .thenReturn(flowOf("testUid")) whenever(preferencesRepository.getPreferenceByKey(com.arygm.quickfix.utils.FIRST_NAME_KEY)) @@ -76,16 +66,16 @@ class ProfileConfigurationUserNoModeScreenTest { .thenReturn(flowOf("john.doe@example.com")) whenever(preferencesRepository.getPreferenceByKey(com.arygm.quickfix.utils.BIRTH_DATE_KEY)) .thenReturn(flowOf("01/01/1990")) + whenever(preferencesRepository.getPreferenceByKey(com.arygm.quickfix.utils.PROFILE_PICTURE_KEY)) + .thenReturn(flowOf("https://example.com/profile.jpg")) whenever(preferencesRepository.getPreferenceByKey(IS_WORKER_KEY)).thenReturn(flowOf(true)) } @Test fun testUpdateFirstNameAndLastName() { - // Arrange + // Mock account update doAnswer { invocation -> - val profile = invocation.getArgument(0) val onSuccess = invocation.getArgument<() -> Unit>(1) - val onFailure = invocation.getArgument<(Exception) -> Unit>(2) onSuccess() null } @@ -101,40 +91,32 @@ class ProfileConfigurationUserNoModeScreenTest { } } - // Update first name and last name using performTextReplacement + // Update first name and last name composeTestRule.onNodeWithTag("firstNameInput").performTextReplacement("Jane") composeTestRule.onNodeWithTag("lastNameInput").performTextReplacement("Smith") // Click Save button composeTestRule.onNodeWithTag("SaveButton").performClick() - // Verify that updateProfile was called with updated names + // Verify account update val profileCaptor = argumentCaptor() verify(accountRepository).updateAccount(profileCaptor.capture(), any(), any()) - - val updatedProfile = profileCaptor.firstValue - assertEquals("Jane", updatedProfile.firstName) - assertEquals("Smith", updatedProfile.lastName) + assertEquals("Jane", profileCaptor.firstValue.firstName) + assertEquals("Smith", profileCaptor.firstValue.lastName) } @Test fun testUpdateEmailWithValidEmail() { - // Arrange + // Mock account exists check and update doAnswer { invocation -> - val email = invocation.getArgument(0) - val onSuccess = invocation.getArgument<(Pair) -> Unit>(1) - val onFailure = invocation.getArgument<(Exception) -> Unit>(2) - // Simulate that the profile does not exist + val onSuccess = invocation.getArgument<(Pair) -> Unit>(1) onSuccess(Pair(false, null)) null } .whenever(accountRepository) .accountExists(any(), any(), any()) - doAnswer { invocation -> - val profile = invocation.getArgument(0) val onSuccess = invocation.getArgument<() -> Unit>(1) - val onFailure = invocation.getArgument<(Exception) -> Unit>(2) onSuccess() null } @@ -150,148 +132,44 @@ class ProfileConfigurationUserNoModeScreenTest { } } - // Update email using performTextReplacement + // Update email composeTestRule.onNodeWithTag("emailInput").performTextReplacement("jane.smith@example.com") // Click Save button composeTestRule.onNodeWithTag("SaveButton").performClick() - // Verify that updateProfile was called with updated email + // Verify account update val profileCaptor = argumentCaptor() verify(accountRepository).updateAccount(profileCaptor.capture(), any(), any()) - - val updatedProfile = profileCaptor.firstValue - assertEquals("jane.smith@example.com", updatedProfile.email) - } - - @Test - fun testUpdateEmailWithInvalidEmailShowsError() { - composeTestRule.setContent { - AccountConfigurationScreen( - navigationActions = navigationActions, - accountViewModel = accountViewModel, - preferencesViewModel = preferencesViewModel) - } - - // Update email with invalid email using performTextReplacement - composeTestRule.onNodeWithTag("emailInput").performTextReplacement("invalidemail") - - // Attempt to click Save button - composeTestRule.onNodeWithTag("SaveButton").performClick() - - // Verify that updateProfile was not called due to invalid email - verify(accountRepository, never()).updateAccount(any(), any(), any()) + assertEquals("jane.smith@example.com", profileCaptor.firstValue.email) } @Test - fun testUpdateBirthDateWithValidDate() { - // Arrange + fun testUpdateProfilePicture() { + // Mock image upload doAnswer { invocation -> - val profile = invocation.getArgument(0) - val onSuccess = invocation.getArgument<() -> Unit>(1) - val onFailure = invocation.getArgument<(Exception) -> Unit>(2) - onSuccess() + val onSuccess = + invocation.getArgument<(List) -> Unit>( + 2) // Third argument is the success callback + onSuccess( + listOf("https://example.com/new-profile.jpg")) // Simulate success with a new profile + // picture URL null } .whenever(accountRepository) - .updateAccount(any(), any(), any()) - - composeTestRule.setContent { - QuickFixTheme { - AccountConfigurationScreen( - navigationActions = navigationActions, - accountViewModel = accountViewModel, - preferencesViewModel = preferencesViewModel) - } - } - - // Update birth date using performTextReplacement - composeTestRule.onNodeWithTag("birthDateInput").performTextReplacement("01/01/1990") - - // Click Save button - composeTestRule.onNodeWithTag("SaveButton").performClick() - - // Verify that updateProfile was called with updated birth date - val profileCaptor = argumentCaptor() - verify(accountRepository).updateAccount(profileCaptor.capture(), any(), any()) - - val updatedProfile = profileCaptor.firstValue - - val calendar = GregorianCalendar(1990, Calendar.JANUARY, 1, 0, 0, 0) - val expectedTimestamp = Timestamp(calendar.time) - - assertEquals(expectedTimestamp.seconds, updatedProfile.birthDate.seconds) - } - - @Test - fun testUpdateBirthDateWithInvalidDateShowsToast() { - composeTestRule.setContent { - QuickFixTheme { - AccountConfigurationScreen( - navigationActions = navigationActions, - accountViewModel = accountViewModel, - preferencesViewModel = preferencesViewModel) - } - } - - // Update birth date with invalid date using performTextReplacement - composeTestRule.onNodeWithTag("birthDateInput").performTextReplacement("invalid-date") - - // Click Save button - composeTestRule.onNodeWithTag("SaveButton").performClick() - - // Verify that updateProfile was not called due to invalid date - verify(accountRepository, never()).updateAccount(any(), any(), any()) - } + .uploadAccountImages(any(), any(), any(), any()) - @Test - fun testChangePasswordButtonClick() { - composeTestRule.setContent { - QuickFixTheme { - AccountConfigurationScreen( - navigationActions = navigationActions, - accountViewModel = accountViewModel, - preferencesViewModel = preferencesViewModel) - } - } - - // Click Change Password button - composeTestRule.onNodeWithTag("ChangePasswordButton").performClick() - - // Since the action is not implemented, verify that nothing crashes - } - - @Test - fun testSaveButtonUpdatesLoggedInProfile() { - // Arrange + // Mock account update doAnswer { invocation -> - val profile = invocation.getArgument(0) - val onSuccess = invocation.getArgument<() -> Unit>(1) - val onFailure = invocation.getArgument<(Exception) -> Unit>(2) - onSuccess() + val onSuccess = + invocation.getArgument<() -> Unit>(1) // Second argument is the success callback + onSuccess() // Simulate success null } .whenever(accountRepository) .updateAccount(any(), any(), any()) - doAnswer { invocation -> - val uid = invocation.getArgument(0) - val onSuccess = invocation.getArgument<(Account?) -> Unit>(1) - val onFailure = invocation.getArgument<(Exception) -> Unit>(2) - val updatedProfile = - Account( - uid = testUserProfile.uid, - firstName = "Jane", - lastName = testUserProfile.lastName, - email = testUserProfile.email, - birthDate = testUserProfile.birthDate, - isWorker = testUserProfile.isWorker) - onSuccess(updatedProfile) - null - } - .whenever(accountRepository) - .getAccountById(any(), any(), any()) - + // Set up the UI composeTestRule.setContent { QuickFixTheme { AccountConfigurationScreen( @@ -301,51 +179,29 @@ class ProfileConfigurationUserNoModeScreenTest { } } - // Update first name using performTextReplacement - composeTestRule.onNodeWithTag("firstNameInput").performTextReplacement("Jane") - - // Click Save button - composeTestRule.onNodeWithTag("SaveButton").performClick() - - // Wait for UI to update - composeTestRule.waitForIdle() + // Simulate clicking the profile image to trigger selection + composeTestRule.onNodeWithTag("ProfileImage").performClick() - // Check that the displayed name is updated - composeTestRule.onNodeWithTag("AccountName").assertTextEquals("Jane Doe") - } - - @Test - fun testSaveButtonNavigatesBack() { - // Arrange - doAnswer { invocation -> - val profile = invocation.getArgument(0) - val onSuccess = invocation.getArgument<() -> Unit>(1) - val onFailure = invocation.getArgument<(Exception) -> Unit>(2) - onSuccess() - null - } - .whenever(accountRepository) - .updateAccount(any(), any(), any()) - - composeTestRule.setContent { - QuickFixTheme { - AccountConfigurationScreen( - navigationActions = navigationActions, - accountViewModel = accountViewModel, - preferencesViewModel = preferencesViewModel) - } + // Simulate selecting a new profile image (mock bitmap) + val testBitmap: Bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + composeTestRule.runOnUiThread { + accountViewModel.uploadAccountImages( + "testUid", listOf(testBitmap), onSuccess = {}, onFailure = {}) } - // Click Save button + // Simulate changing an input field to ensure `isModified` is true + composeTestRule.onNodeWithTag("firstNameInput").performTextReplacement("Jane") + + // Click Save button to trigger account update composeTestRule.onNodeWithTag("SaveButton").performClick() - // Verify that navigationActions.goBack() was called - verify(navigationActions).goBack() + // Verify that the account update includes the new profile picture URL + val profileCaptor = argumentCaptor() + verify(accountRepository).updateAccount(profileCaptor.capture(), any(), any()) } @Test - fun testInitialUIElementsAreDisplayed() { - // Set up the content + fun testSaveButtonDisablesForInvalidInputs() { composeTestRule.setContent { QuickFixTheme { AccountConfigurationScreen( @@ -355,27 +211,12 @@ class ProfileConfigurationUserNoModeScreenTest { } } - // Verify that the Top App Bar is displayed with the correct title - composeTestRule.onNodeWithTag("AccountConfigurationTopAppBar").assertIsDisplayed() - composeTestRule - .onNodeWithTag("AccountConfigurationTitle") - .assertTextEquals("Account configuration") - - // Verify that the Profile Image is displayed - composeTestRule.onNodeWithTag("AccountImage").assertIsDisplayed() + // Enter invalid email + composeTestRule.onNodeWithTag("emailInput").performTextReplacement("invalid-email") + composeTestRule.onNodeWithTag("SaveButton").assertIsNotEnabled() - // Verify that the Profile Card is displayed with the correct name - composeTestRule.onNodeWithTag("AccountCard").assertIsDisplayed() - composeTestRule.onNodeWithTag("AccountName").assertTextEquals("John Doe") - - // Verify that the input fields are displayed - composeTestRule.onNodeWithTag("firstNameInput").assertIsDisplayed() - composeTestRule.onNodeWithTag("lastNameInput").assertIsDisplayed() - composeTestRule.onNodeWithTag("emailInput").assertIsDisplayed() - composeTestRule.onNodeWithTag("birthDateInput").assertIsDisplayed() - - // Verify that the buttons are displayed - composeTestRule.onNodeWithTag("ChangePasswordButton").assertIsDisplayed() - composeTestRule.onNodeWithTag("SaveButton").assertIsDisplayed() + // Enter invalid birth date + composeTestRule.onNodeWithTag("birthDateInput").performTextReplacement("invalid-date") + composeTestRule.onNodeWithTag("SaveButton").assertIsNotEnabled() } } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementCardTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementCardTest.kt new file mode 100644 index 00000000..74c23997 --- /dev/null +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementCardTest.kt @@ -0,0 +1,354 @@ +package com.arygm.quickfix.ui.search + +import android.graphics.Bitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.arygm.quickfix.model.account.Account +import com.arygm.quickfix.model.account.AccountViewModel +import com.arygm.quickfix.model.category.Category +import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.locations.Location +import com.arygm.quickfix.model.search.Announcement +import com.arygm.quickfix.ui.theme.QuickFixTheme +import com.arygm.quickfix.ui.uiMode.appContentUI.workerMode.announcements.AnnouncementCard +import com.google.firebase.Timestamp +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AnnouncementCardTest { + + @get:Rule val composeTestRule = createComposeRule() + + private lateinit var categoryViewModel: CategoryViewModel + private lateinit var accountViewModel: AccountViewModel + + private val sampleAnnouncement = + Announcement( + announcementId = "testAnn", + userId = "user123", + title = "Test Announcement", + category = "testSubCat", + description = "A test announcement description", + location = Location(10.0, 10.0, "Test Location"), + availability = emptyList(), + quickFixImages = emptyList()) + private val timestamp = Timestamp.now() + + @Before + fun setup() { + categoryViewModel = mockk(relaxed = true) + accountViewModel = mockk(relaxed = true) + } + + @Test + fun announcementCard_withImage_andCategory_andAccount() { + // Mock category found + every { + runBlocking { categoryViewModel.getCategoryBySubcategoryId(eq("testSubCat"), any()) } + } answers + { + val onSuccess = arg<(Category?) -> Unit>(1) + onSuccess( + Category( + id = "catId", + name = "Mock Category", + description = "A mock category", + subcategories = emptyList())) + } + + // Mock account found + every { accountViewModel.fetchUserAccount(eq("user123"), any()) } answers + { + val onResult = arg<(Account?) -> Unit>(1) + onResult( + Account( + uid = "user123", + firstName = "John", + lastName = "Doe", + email = "john@doe.com", + birthDate = timestamp)) + } + + val dummyBitmap = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888) + + var onClickCalled = false + + composeTestRule.setContent { + QuickFixTheme { + AnnouncementCard( + announcement = sampleAnnouncement, + announcementImage = dummyBitmap, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel) { + onClickCalled = true + } + } + } + + composeTestRule.waitForIdle() + + // Verify image displayed + composeTestRule + .onNodeWithTag("AnnouncementImage_testAnn", useUnmergedTree = true) + .assertExists() + + // Verify title + composeTestRule + .onNodeWithTag("AnnouncementTitle_testAnn", useUnmergedTree = true) + .assertTextEquals("Test Announcement") + + // Verify description + composeTestRule + .onNodeWithTag("AnnouncementDescription_testAnn", useUnmergedTree = true) + .assertTextEquals("A test announcement description") + + // Verify location + composeTestRule + .onNodeWithTag("AnnouncementLocation_testAnn", useUnmergedTree = true) + .assertTextEquals("Test Location") + + // Verify user name + composeTestRule + .onNodeWithTag("AnnouncementUserName_testAnn", useUnmergedTree = true) + .assertTextContains("By John D.") + + // Click card + composeTestRule.onNodeWithTag("AnnouncementCard_testAnn", useUnmergedTree = true).performClick() + assertTrue(onClickCalled) + } + + @Test + fun announcementCard_noImage_noCategory_noAccount_noLocation() { + // Mock category returns null + every { + runBlocking { categoryViewModel.getCategoryBySubcategoryId(eq("testSubCat"), any()) } + } answers + { + val onSuccess = arg<(Category?) -> Unit>(1) + onSuccess(null) + } + + // Mock account returns null + every { accountViewModel.fetchUserAccount(eq("user123"), any()) } answers + { + val onResult = arg<(Account?) -> Unit>(1) + onResult(null) + } + + val noLocationAnnouncement = sampleAnnouncement.copy(location = null) + + composeTestRule.setContent { + QuickFixTheme { + AnnouncementCard( + announcement = noLocationAnnouncement, + announcementImage = null, // no image + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel) {} + } + } + + composeTestRule.waitForIdle() + + // Verify loader displayed instead of image + composeTestRule + .onNodeWithTag("AnnouncementImagePlaceholder_testAnn", useUnmergedTree = true) + .assertExists() + composeTestRule.onNodeWithTag("Loader_testAnn", useUnmergedTree = true).assertExists() + + // Verify title + composeTestRule + .onNodeWithTag("AnnouncementTitle_testAnn", useUnmergedTree = true) + .assertTextEquals("Test Announcement") + + // Description should still be correct + composeTestRule + .onNodeWithTag("AnnouncementDescription_testAnn", useUnmergedTree = true) + .assertTextEquals("A test announcement description") + + // Location now unknown + composeTestRule + .onNodeWithTag("AnnouncementLocation_testAnn", useUnmergedTree = true) + .assertTextEquals("Unknown") + + // By Unknown user + composeTestRule + .onNodeWithTag("AnnouncementUserName_testAnn", useUnmergedTree = true) + .assertTextEquals("By Unknown") + + // Category icon will still appear even with null category, default icon scenario + composeTestRule + .onNodeWithTag("AnnouncementCategoryIcon_testAnn", useUnmergedTree = true) + .assertExists() + } + + @Test + fun announcementCard_noAccountInitially_showsUnknown() { + // Initially return null account + every { accountViewModel.fetchUserAccount(eq("user123"), any()) } answers + { + val onResult = arg<(Account?) -> Unit>(1) + onResult(null) + } + + // category found scenario + every { + runBlocking { categoryViewModel.getCategoryBySubcategoryId(eq("testSubCat"), any()) } + } answers + { + val onSuccess = arg<(Category?) -> Unit>(1) + onSuccess( + Category( + id = "catId", + name = "Mock Category", + description = "A mock category", + subcategories = emptyList())) + } + + composeTestRule.setContent { + QuickFixTheme { + AnnouncementCard( + announcement = sampleAnnouncement, + announcementImage = null, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel) {} + } + } + + composeTestRule.waitForIdle() + + // Verify initially "By Unknown" + composeTestRule + .onNodeWithTag("AnnouncementUserName_testAnn", useUnmergedTree = true) + .assertTextEquals("By Unknown") + } + + @Test + fun announcementCard_accountFetchedInitially_showsUserName() { + // Now return an account right away + val laterAccount = + Account( + uid = "user123", + firstName = "Alice", + lastName = "Smith", + email = "a@b.com", + birthDate = timestamp) + + every { accountViewModel.fetchUserAccount(eq("user123"), any()) } answers + { + val onResult = arg<(Account?) -> Unit>(1) + onResult(laterAccount) + } + + every { + runBlocking { categoryViewModel.getCategoryBySubcategoryId(eq("testSubCat"), any()) } + } answers + { + val onSuccess = arg<(Category?) -> Unit>(1) + onSuccess( + Category( + id = "catId", + name = "Mock Category", + description = "A mock category", + subcategories = emptyList())) + } + + composeTestRule.setContent { + QuickFixTheme { + AnnouncementCard( + announcement = sampleAnnouncement, + announcementImage = null, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel) {} + } + } + + composeTestRule.waitForIdle() + + // Check that user name is shown right away + composeTestRule + .onNodeWithTag("AnnouncementUserName_testAnn", useUnmergedTree = true) + .assertTextContains("By Alice S.") + } + + @Test + fun announcementCard_noCategoryInitially_showsDefaultIcon() { + // Initially return null category + every { + runBlocking { categoryViewModel.getCategoryBySubcategoryId(eq("testSubCat"), any()) } + } answers + { + val onSuccess = arg<(Category?) -> Unit>(1) + onSuccess(null) + } + + every { accountViewModel.fetchUserAccount(eq("user123"), any()) } answers + { + val onResult = arg<(Account?) -> Unit>(1) + onResult(null) + } + + composeTestRule.setContent { + QuickFixTheme { + AnnouncementCard( + announcement = sampleAnnouncement, + announcementImage = null, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel) {} + } + } + + composeTestRule.waitForIdle() + + // Category is null => default category icon + composeTestRule + .onNodeWithTag("AnnouncementCategoryIcon_testAnn", useUnmergedTree = true) + .assertExists() + } + + @Test + fun announcementCard_categoryFetchedInitially_showsCategoryIcon() { + every { + runBlocking { categoryViewModel.getCategoryBySubcategoryId(eq("testSubCat"), any()) } + } answers + { + val onSuccess = arg<(Category?) -> Unit>(1) + onSuccess( + Category( + id = "catId", + name = "Later Category", + description = "", + subcategories = emptyList())) + } + + every { accountViewModel.fetchUserAccount(eq("user123"), any()) } answers + { + val onResult = arg<(Account?) -> Unit>(1) + onResult(null) + } + + composeTestRule.setContent { + QuickFixTheme { + AnnouncementCard( + announcement = sampleAnnouncement, + announcementImage = null, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel) {} + } + } + + composeTestRule.waitForIdle() + + // Check icon exists after category found initially + composeTestRule + .onNodeWithTag("AnnouncementCategoryIcon_testAnn", useUnmergedTree = true) + .assertExists() + } +} diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementDetailTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementDetailTest.kt index 3e2ffdef..eac29acb 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementDetailTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementDetailTest.kt @@ -106,7 +106,7 @@ class AnnouncementDetailTest { AnnouncementViewModel( announcementRepository = mockAnnouncementRepository, preferencesRepository = mockPreferencesRepository, - userProfileRepository = mockProfileRepository) + profileRepository = mockProfileRepository) // Select the sample announcement announcementViewModel.selectAnnouncement(sampleAnnouncement) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementsScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementsScreenTest.kt new file mode 100644 index 00000000..4daef6f3 --- /dev/null +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/AnnouncementsScreenTest.kt @@ -0,0 +1,353 @@ +package com.arygm.quickfix.ui.search + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.arygm.quickfix.model.account.AccountViewModel +import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.locations.Location +import com.arygm.quickfix.model.offline.small.PreferencesRepository +import com.arygm.quickfix.model.offline.small.PreferencesViewModel +import com.arygm.quickfix.model.profile.ProfileRepository +import com.arygm.quickfix.model.profile.ProfileViewModel +import com.arygm.quickfix.model.profile.WorkerProfile +import com.arygm.quickfix.model.search.Announcement +import com.arygm.quickfix.model.search.AnnouncementRepository +import com.arygm.quickfix.model.search.AnnouncementViewModel +import com.arygm.quickfix.ui.navigation.NavigationActions +import com.arygm.quickfix.ui.uiMode.appContentUI.workerMode.announcements.AnnouncementsScreen +import com.arygm.quickfix.ui.uiMode.workerMode.navigation.WorkerScreen +import com.arygm.quickfix.utils.UID_KEY +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.* +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever + +class AnnouncementsScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + private lateinit var announcementViewModel: AnnouncementViewModel + private lateinit var preferencesViewModel: PreferencesViewModel + private lateinit var workerProfileViewModel: ProfileViewModel + private lateinit var categoryViewModel: CategoryViewModel + private lateinit var accountViewModel: AccountViewModel + private lateinit var mockNavigationActions: NavigationActions + + private lateinit var mockAnnouncementRepository: AnnouncementRepository + private lateinit var mockPreferencesRepository: PreferencesRepository + private lateinit var mockProfileRepository: ProfileRepository + + private val userIdFlow = MutableStateFlow("workerUserId") + + private val sampleAnnouncements = + listOf( + Announcement( + announcementId = "ann1", + userId = "user1", + title = "Announcement 1", + category = "cat1", + description = "Desc1", + location = Location(10.0, 10.0, "Loc1"), + availability = emptyList(), + quickFixImages = emptyList()), + Announcement( + announcementId = "ann2", + userId = "user2", + title = "Announcement 2", + category = "cat2", + description = "Desc2", + location = Location(20.0, 20.0, "Loc2"), + availability = emptyList(), + quickFixImages = emptyList())) + + @Before + fun setup() { + mockAnnouncementRepository = mock(AnnouncementRepository::class.java) + mockPreferencesRepository = mock(PreferencesRepository::class.java) + mockProfileRepository = mock(ProfileRepository::class.java) + categoryViewModel = mockk(relaxed = true) + accountViewModel = mockk(relaxed = true) + workerProfileViewModel = ProfileViewModel(mockProfileRepository) + + mockNavigationActions = mock(NavigationActions::class.java) + + preferencesViewModel = PreferencesViewModel(mockPreferencesRepository) + + val userIdKey = UID_KEY + whenever(mockPreferencesRepository.getPreferenceByKey(userIdKey)).thenReturn(userIdFlow) + + // Mock announcements flow + // By default, no announcements are set until we decide to call getAnnouncements or set them + // We'll directly manipulate the announcements in AnnouncementViewModel by mocking the + // repository calls. + doAnswer { invocation -> + val onSuccess = invocation.arguments[0] as (List) -> Unit + onSuccess(sampleAnnouncements) + null + } + .whenever(mockAnnouncementRepository) + .getAnnouncements(any(), any()) + + // Mock init call + doAnswer { invocation -> + val onSuccess = invocation.arguments[0] as () -> Unit + onSuccess() + null + } + .whenever(mockAnnouncementRepository) + .init(any()) + + // Initialize view model + announcementViewModel = + AnnouncementViewModel( + announcementRepository = mockAnnouncementRepository, + preferencesRepository = mockPreferencesRepository, + profileRepository = mockProfileRepository) + + // Set announcements + announcementViewModel.getAnnouncements() + + // Mock fetching worker profile after userId is loaded + doAnswer { invocation -> + val uid = invocation.arguments[0] as String + val onSuccess = invocation.arguments[1] as (Any?) -> Unit + // Return a WorkerProfile + onSuccess(WorkerProfile(location = Location(30.0, 30.0, "WorkerLoc"), uid = uid)) + null + } + .whenever(mockProfileRepository) + .getProfileById(anyString(), any(), any()) + } + + @Test + fun announcementsScreen_displaysProperly() { + composeTestRule.setContent { + AnnouncementsScreen( + announcementViewModel = announcementViewModel, + preferencesViewModel = preferencesViewModel, + workerProfileViewModel = workerProfileViewModel, + categoryViewModel = categoryViewModel, + accountViewModel = accountViewModel, + navigationActions = mockNavigationActions) + } + + // Verify base UI elements + composeTestRule.onNodeWithTag("announcements_screen").assertExists() + composeTestRule.onNodeWithTag("announcements_scaffold").assertExists() + composeTestRule.onNodeWithTag("main_column").assertExists() + composeTestRule.onNodeWithTag("title_column").assertExists() + composeTestRule.onNodeWithTag("announcements_title").assertTextEquals("Announcements for you") + composeTestRule + .onNodeWithTag("announcements_subtitle") + .assertTextEquals("Here are announcements that matches your profile") + } + + @Test + fun tuneButton_togglesFilterVisibility() { + composeTestRule.setContent { + AnnouncementsScreen( + announcementViewModel = announcementViewModel, + preferencesViewModel = preferencesViewModel, + workerProfileViewModel = workerProfileViewModel, + categoryViewModel = categoryViewModel, + accountViewModel = accountViewModel, + navigationActions = mockNavigationActions) + } + + // Initially, the filter buttons row is there, but the LazyRow with filters is hidden + composeTestRule.onNodeWithTag("tuneButton").assertExists() + // Since the visibility is false initially, "filter_button_Clear" should not be found + composeTestRule.onNodeWithTag("filter_button_Clear").assertDoesNotExist() + + // Click tune button -> show filters + composeTestRule.onNodeWithTag("tuneButton").performClick() + // Now the filters should be visible + composeTestRule.onNodeWithTag("filter_button_Clear").assertExists() + + // Click tune button again -> hide filters + composeTestRule.onNodeWithTag("tuneButton").performClick() + // "filter_button_Clear" should no longer exist + composeTestRule.onNodeWithTag("filter_button_Clear").assertDoesNotExist() + } + + @Test + fun announcements_areDisplayed_andClickable() { + composeTestRule.setContent { + AnnouncementsScreen( + announcementViewModel = announcementViewModel, + preferencesViewModel = preferencesViewModel, + workerProfileViewModel = workerProfileViewModel, + categoryViewModel = categoryViewModel, + accountViewModel = accountViewModel, + navigationActions = mockNavigationActions) + } + + // Check that announcements are displayed + composeTestRule.onNodeWithTag("worker_profiles_list").assertExists() + composeTestRule.onNodeWithTag("announcement_0").assertExists() + composeTestRule.onNodeWithTag("announcement_1").assertExists() + + // Click on an announcement + composeTestRule.onNodeWithTag("announcement_0").performClick() + verify(mockNavigationActions).navigateTo(eq(WorkerScreen.ANNOUNCEMENT_DETAIL)) + } + + @Test + fun clearFilterButton_resetsFilters() { + composeTestRule.setContent { + AnnouncementsScreen( + announcementViewModel = announcementViewModel, + preferencesViewModel = preferencesViewModel, + workerProfileViewModel = workerProfileViewModel, + categoryViewModel = categoryViewModel, + accountViewModel = accountViewModel, + navigationActions = mockNavigationActions) + } + + // Show filters + composeTestRule.onNodeWithTag("tuneButton").performClick() + + // Click clear button + composeTestRule.onNodeWithTag("filter_button_Clear").performClick() + + // Filters should be reset. Since we had no actual modifications, just ensure no crash + // and announcements remain displayed + composeTestRule.onNodeWithTag("announcement_0").assertExists() + composeTestRule.onNodeWithTag("announcement_1").assertExists() + } + + @Test + fun applyLocationFilter_withDefaultLocation_showsToast_andFilters() { + // Initially locationFilterApplied = false + // Show filters + composeTestRule.setContent { + AnnouncementsScreen( + announcementViewModel = announcementViewModel, + preferencesViewModel = preferencesViewModel, + workerProfileViewModel = workerProfileViewModel, + categoryViewModel = categoryViewModel, + accountViewModel = accountViewModel, + navigationActions = mockNavigationActions) + } + + // Show filter buttons + composeTestRule.onNodeWithTag("tuneButton").performClick() + + // Click location button + composeTestRule.onNodeWithTag("filter_button_Location").performClick() + + // The bottom sheet should appear. It's controlled by showLocationBottomSheet + // Since we have a WorkerProfile (mocked), the bottom sheet will show location options. + // We must select the "Use my Current Location" (option 0) + // Perform action: We can't directly interact with QuickFixLocationFilterBottomSheet's internal + // elements + // since not all test tags might be available. We assume "applyButton" testTag from + // QuickFixLocationFilterBottomSheet. + // We'll just simulate the scenario by calling onApplyClick indirectly: + + // To ensure line coverage, we need to reflect a scenario: + // We'll mock a scenario that location chosen is default (0.0,0.0,"Default") and see if Toast + // shows up + // Although we can't directly test Toast easily, we can still ensure no crash occurs. + + // Let's simulate tapping apply from the bottom sheet: + // For that, we must ensure we have a testTag for "applyButton" inside the bottom sheet (already + // added in previous code) + composeTestRule.onNodeWithTag("applyButton").assertIsDisplayed().performClick() + + // Now locationFilterApplied = true + // We triggered a toast scenario but we can't verify toast in Jetpack Compose tests easily, + // just trusting the code runs. Announcements should now be filtered (though we have no real + // distance checks) + composeTestRule.onNodeWithTag("announcement_0").assertExists() + } + + @Test + fun applyLocationFilter_withActualLocation_andThenClear() { + // Let's simulate a scenario where we first show the bottom sheet and apply a real location + // filter + // locationFilterApplied should become true after applying + composeTestRule.setContent { + AnnouncementsScreen( + announcementViewModel = announcementViewModel, + preferencesViewModel = preferencesViewModel, + workerProfileViewModel = workerProfileViewModel, + categoryViewModel = categoryViewModel, + accountViewModel = accountViewModel, + navigationActions = mockNavigationActions) + } + + // Show filters + composeTestRule.onNodeWithTag("tuneButton").performClick() + // Click location to show bottom sheet + composeTestRule.onNodeWithTag("filter_button_Location").performClick() + + // Select a location from the worker profile, index 1 (but we must have test tags from bottom + // sheet) + // For simplicity, we assume we can select the second option (worker's location) + // In your bottomSheet test code, you had a testTag "locationOptionRow1" and "applyButton" + composeTestRule.onNodeWithTag("locationOptionRow1").performClick() + composeTestRule.onNodeWithTag("applyButton").performClick() + + // locationFilterApplied = true now + // Check that announcements still exist + composeTestRule.onNodeWithTag("worker_profiles_list").assertExists() + + // Clear filters now by pressing "Clear" + composeTestRule.onNodeWithTag("filter_button_Clear").performClick() + // locationFilterApplied = false, all announcements should remain visible + composeTestRule.onNodeWithTag("announcement_0", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("announcement_1", useUnmergedTree = true).assertExists() + } + + @Test + fun fetchAnnouncementImagesIsCalledForEachAnnouncement() { + verify(mockAnnouncementRepository, times(1)).getAnnouncements(any(), any()) + + composeTestRule.setContent { + AnnouncementsScreen( + announcementViewModel = announcementViewModel, + preferencesViewModel = preferencesViewModel, + workerProfileViewModel = workerProfileViewModel, + categoryViewModel = categoryViewModel, + accountViewModel = accountViewModel, + navigationActions = mockNavigationActions) + } + + // Now just verify that fetchAnnouncementsImagesAsBitmaps is called once per announcement + verify(mockAnnouncementRepository, times(sampleAnnouncements.size)) + .fetchAnnouncementsImagesAsBitmaps(anyString(), any(), any()) + } + + @Test + fun alreadyAppliedLocationFilter_reapplyFiltersAfterNewLocation() { + composeTestRule.setContent { + AnnouncementsScreen( + announcementViewModel = announcementViewModel, + preferencesViewModel = preferencesViewModel, + workerProfileViewModel = workerProfileViewModel, + categoryViewModel = categoryViewModel, + accountViewModel = accountViewModel, + navigationActions = mockNavigationActions) + } + + // Show filters + composeTestRule.onNodeWithTag("tuneButton").performClick() + // Click location + composeTestRule.onNodeWithTag("filter_button_Location").performClick() + // Select current location and apply + composeTestRule.onNodeWithTag("locationOptionRow0").performClick() + composeTestRule.onNodeWithTag("applyButton").performClick() + + composeTestRule.onNodeWithTag("filter_button_Location").performClick() + composeTestRule.onNodeWithTag("locationOptionRow1").performClick() + composeTestRule.onNodeWithTag("applyButton").performClick() + + composeTestRule.onNodeWithTag("worker_profiles_list").assertExists() + } +} diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt index 2bfa074e..16e4e118 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt @@ -1,5 +1,6 @@ package com.arygm.quickfix.ui.search +import android.graphics.Bitmap import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule @@ -10,6 +11,8 @@ import com.arygm.quickfix.model.account.AccountRepositoryFirestore import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryRepositoryFirestore import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.profile.ProfileRepository +import com.arygm.quickfix.model.profile.ProfileViewModel import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.search.SearchViewModel @@ -34,6 +37,8 @@ class ProfileResultsTest { private lateinit var accountViewModel: AccountViewModel private lateinit var categoryViewModel: CategoryViewModel private lateinit var navigationActionsRoot: NavigationActions + private lateinit var workerRepository: ProfileRepository + private lateinit var workerViewModel: ProfileViewModel @get:Rule val composeTestRule = createComposeRule() @@ -47,6 +52,26 @@ class ProfileResultsTest { searchViewModel = SearchViewModel(workerProfileRepo) categoryViewModel = CategoryViewModel(categoryRepo) accountViewModel = mockk(relaxed = true) + + workerViewModel = mockk(relaxed = true) + + // Mock fetchProfileImageAsBitmap + every { workerViewModel.fetchProfileImageAsBitmap(any(), any(), any()) } answers + { + val onSuccess = arg<(Bitmap) -> Unit>(1) + // Provide a dummy bitmap here (e.g. a solid color bitmap or decode from resources) + val dummyBitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888) + onSuccess(dummyBitmap) // Simulate success callback + } + + // Mock fetchBannerImageAsBitmap + every { workerViewModel.fetchBannerImageAsBitmap(any(), any(), any()) } answers + { + val onSuccess = arg<(Bitmap) -> Unit>(1) + // Provide another dummy bitmap + val dummyBitmap = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888) + onSuccess(dummyBitmap) // Simulate success callback + } } @Test @@ -103,6 +128,7 @@ class ProfileResultsTest { listState = rememberLazyListState(), searchViewModel = searchViewModel, accountViewModel = accountViewModel, + workerViewModel = workerViewModel, onBookClick = { _, _ -> }) } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderScreenTest.kt index 5746eca4..59b0118a 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixFinderScreenTest.kt @@ -43,9 +43,12 @@ class QuickFixFinderScreenTest { private lateinit var accountViewModel: AccountViewModel private lateinit var categoryViewModel: CategoryViewModel private lateinit var quickFixViewModel: QuickFixViewModel + private lateinit var workerViewModel: ProfileViewModel + private lateinit var workerProfileRepository: ProfileRepository @Before fun setup() { + workerProfileRepository = mock(ProfileRepository::class.java) navigationActions = mock(NavigationActions::class.java) navigationActionsRoot = mock(NavigationActions::class.java) @@ -70,6 +73,7 @@ class QuickFixFinderScreenTest { categoryViewModel = CategoryViewModel(categoryRepo) accountViewModel = mockk(relaxed = true) quickFixViewModel = QuickFixViewModel(mock()) + workerViewModel = ProfileViewModel(workerProfileRepository) } @Test @@ -85,13 +89,12 @@ class QuickFixFinderScreenTest { announcementViewModel = announcementViewModel, categoryViewModel = categoryViewModel, preferencesViewModel = preferencesViewModel, - quickFixViewModel = quickFixViewModel) + quickFixViewModel = quickFixViewModel, + workerViewModel = workerViewModel) } // Assert top bar is displayed composeTestRule.onNodeWithTag("QuickFixFinderTopBar").assertIsDisplayed() - composeTestRule.onNodeWithTag("QuickFixFinderTopBarTitle").assertIsDisplayed() - composeTestRule.onNodeWithTag("QuickFixFinderTopBarTitle").assertTextEquals("Quickfix") // Assert main content is displayed composeTestRule.onNodeWithTag("QuickFixFinderContent").assertIsDisplayed() @@ -124,7 +127,8 @@ class QuickFixFinderScreenTest { announcementViewModel = announcementViewModel, categoryViewModel = categoryViewModel, preferencesViewModel = preferencesViewModel, - quickFixViewModel = quickFixViewModel) + quickFixViewModel = quickFixViewModel, + workerViewModel = workerViewModel) } composeTestRule.waitForIdle() @@ -149,7 +153,8 @@ class QuickFixFinderScreenTest { announcementViewModel = announcementViewModel, categoryViewModel = categoryViewModel, preferencesViewModel = preferencesViewModel, - quickFixViewModel = quickFixViewModel) + quickFixViewModel = quickFixViewModel, + workerViewModel = workerViewModel) } // Click on the "Announce" tab @@ -172,7 +177,8 @@ class QuickFixFinderScreenTest { announcementViewModel = announcementViewModel, categoryViewModel = categoryViewModel, preferencesViewModel = preferencesViewModel, - quickFixViewModel = quickFixViewModel) + quickFixViewModel = quickFixViewModel, + workerViewModel = workerViewModel) } // Click on the "Search" tab diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt index 9447fe96..c4723ed7 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performTextInput @@ -27,6 +28,8 @@ import com.arygm.quickfix.model.category.CategoryViewModel import com.arygm.quickfix.model.category.Subcategory import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.WorkerProfile +import com.arygm.quickfix.model.profile.ProfileRepository +import com.arygm.quickfix.model.profile.ProfileViewModel import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.quickfix.QuickFixViewModel import com.arygm.quickfix.model.search.SearchViewModel @@ -54,11 +57,15 @@ class SearchOnBoardingTest { private lateinit var categoryViewModel: CategoryViewModel private lateinit var navigationActionsRoot: NavigationActions private lateinit var quickFixViewModel: QuickFixViewModel + private lateinit var workerViewModel: ProfileViewModel + private lateinit var workerProfileRepository: ProfileRepository @get:Rule val composeTestRule = createComposeRule() @Before fun setup() { + workerProfileRepository = mock(ProfileRepository::class.java) + workerViewModel = ProfileViewModel(workerProfileRepository) navigationActions = mock(NavigationActions::class.java) navigationActionsRoot = mock(NavigationActions::class.java) workerProfileRepo = mockk(relaxed = true) @@ -80,7 +87,8 @@ class SearchOnBoardingTest { accountViewModel, categoryViewModel, onProfileClick = { _, _ -> }, - ) + + workerViewModel) } // Check that the search input field is displayed @@ -101,7 +109,7 @@ class SearchOnBoardingTest { accountViewModel, categoryViewModel, onProfileClick = { _, _ -> }, - ) + workerViewModel) } // Input text into the search field @@ -131,7 +139,7 @@ class SearchOnBoardingTest { accountViewModel, categoryViewModel, onProfileClick = { _, _ -> }, - ) + workerViewModel) } // Verify initial state (Categories are displayed) @@ -152,7 +160,7 @@ class SearchOnBoardingTest { accountViewModel, categoryViewModel, onProfileClick = { _, _ -> }, - ) + workerViewModel) } // Input text to simulate non-empty search @@ -197,7 +205,7 @@ class SearchOnBoardingTest { accountViewModel = accountViewModel, categoryViewModel = categoryViewModel, onProfileClick = { _, _ -> }, - ) + workerViewModel) } // Wait for the UI to settle @@ -272,7 +280,8 @@ class SearchOnBoardingTest { accountViewModel = accountViewModel, categoryViewModel = categoryViewModel, onProfileClick = { _, _ -> }, - ) + workerViewModel) + } // Perform a search query to display filter buttons @@ -310,7 +319,7 @@ class SearchOnBoardingTest { accountViewModel = accountViewModel, categoryViewModel = categoryViewModel, onProfileClick = { _, _ -> }, - ) + workerViewModel) } // Perform a search query to display filter buttons @@ -323,6 +332,7 @@ class SearchOnBoardingTest { // Verify the bottom sheet appears composeTestRule.waitForIdle() + composeTestRule.onRoot().printToLog("root") composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() } @@ -336,7 +346,7 @@ class SearchOnBoardingTest { accountViewModel, categoryViewModel, onProfileClick = { _, _ -> }, - ) + workerViewModel) } // Click the Cancel button @@ -360,7 +370,7 @@ class SearchOnBoardingTest { accountViewModel, categoryViewModel, onProfileClick = { _, _ -> }, - ) + workerViewModel) } // Perform a search to show filters @@ -388,7 +398,7 @@ class SearchOnBoardingTest { accountViewModel, categoryViewModel, onProfileClick = { _, _ -> }, - ) + workerViewModel) } // Perform a search to show filters diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerProfileResultTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerProfileResultTest.kt index 8dae3a00..4b6a0573 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerProfileResultTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerProfileResultTest.kt @@ -1,12 +1,12 @@ package com.arygm.quickfix.ui.search +import android.graphics.Bitmap import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.arygm.quickfix.R import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchWorkerProfileResult import org.junit.Rule import org.junit.Test @@ -21,7 +21,7 @@ class SearchWorkerProfileResultTest { fun testProfileImageIsDisplayed() { composeTestRule.setContent { SearchWorkerProfileResult( - profileImage = R.drawable.placeholder_worker, + profileImage = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888), name = "Moha Abbes", category = "Exterior Painter", rating = 4.0, @@ -41,7 +41,7 @@ class SearchWorkerProfileResultTest { fun testProfileDetailsAreDisplayed() { composeTestRule.setContent { SearchWorkerProfileResult( - profileImage = R.drawable.placeholder_worker, + profileImage = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888), name = "Moha Abbes", category = "Exterior Painter", rating = 4.0, @@ -69,7 +69,7 @@ class SearchWorkerProfileResultTest { var clicked = false composeTestRule.setContent { SearchWorkerProfileResult( - profileImage = R.drawable.placeholder_worker, + profileImage = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888), name = "Moha Abbes", category = "Exterior Painter", rating = 4.0, diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt index 96c8123d..4cc0fec6 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.Activity import android.content.Context import android.content.pm.PackageManager +import android.graphics.Bitmap import android.location.LocationManager import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals @@ -57,6 +58,8 @@ import com.arygm.quickfix.utils.LocationHelper import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.tasks.OnCompleteListener import com.google.android.gms.tasks.Task +import io.mockk.every +import io.mockk.mockk import java.time.LocalDate import java.time.LocalTime import kotlin.math.roundToInt @@ -91,6 +94,7 @@ class SearchWorkerResultScreenTest { private lateinit var preferencesRepositoryDataStore: PreferencesRepository private lateinit var quickFixRepositoryFirestore: QuickFixRepositoryFirestore private lateinit var quickFixViewModel: QuickFixViewModel + private lateinit var workerViewModel: ProfileViewModel private lateinit var context: Context private lateinit var activity: Activity private lateinit var locationHelper: LocationHelper @@ -102,7 +106,7 @@ class SearchWorkerResultScreenTest { fun setup() { // Initialize Mockito MockitoAnnotations.openMocks(this) - + workerRepository = mock(WorkerProfileRepositoryFirestore::class.java) // Mock dependencies navigationActions = mock(NavigationActions::class.java) workerRepository = mock(WorkerProfileRepositoryFirestore::class.java) @@ -111,6 +115,7 @@ class SearchWorkerResultScreenTest { quickFixRepositoryFirestore = mock(QuickFixRepositoryFirestore::class.java) userProfileRepositoryFirestore = mock(ProfileRepository::class.java) preferencesRepositoryDataStore = mock(PreferencesRepository::class.java) + workerViewModel = ProfileViewModel(workerRepository) // Mock the flow returned by the repository val mockedPreferenceFlow = MutableStateFlow(null) @@ -135,8 +140,8 @@ class SearchWorkerResultScreenTest { fieldOfWork = "Carpentry", rating = 3.0, description = "I hate my job", - location = Location(40.7128, -74.0060, "Ecublens, VD"), - )) + location = Location(40.7128, -74.0060), + displayName = "Ramo")) // Mock the getAccountById method to always return a test Account doAnswer { invocation -> @@ -177,6 +182,26 @@ class SearchWorkerResultScreenTest { } .`when`(userProfileRepositoryFirestore) .getProfileById(anyString(), any(), any()) + + workerViewModel = mockk(relaxed = true) + + // Mock fetchProfileImageAsBitmap + every { workerViewModel.fetchProfileImageAsBitmap(any(), any(), any()) } answers + { + val onSuccess = arg<(Bitmap) -> Unit>(1) + // Provide a dummy bitmap here (e.g. a solid color bitmap or decode from resources) + val dummyBitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888) + onSuccess(dummyBitmap) // Simulate success callback + } + + // Mock fetchBannerImageAsBitmap + every { workerViewModel.fetchBannerImageAsBitmap(any(), any(), any()) } answers + { + val onSuccess = arg<(Bitmap) -> Unit>(1) + // Provide another dummy bitmap + val dummyBitmap = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888) + onSuccess(dummyBitmap) // Simulate success callback + } } @Test @@ -189,7 +214,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } // Verify that Back and Search icons are present in the top bar composeTestRule.onNodeWithContentDescription("Back").assertExists().assertIsDisplayed() @@ -205,14 +231,15 @@ class SearchWorkerResultScreenTest { searchViewModel, accountViewModel, userViewModel, - quickFixViewModel, - preferencesViewModel) + quickFixViewModel, + preferencesViewModel, + workerViewModel = workerViewModel) } // Set the search query and verify that the title and description match the query searchViewModel.setSearchQuery("Unknown") // Check if the description with the query text is displayed - composeTestRule.onAllNodesWithText("Unknown").assertCountEquals(2) + composeTestRule.onAllNodesWithText("Unknown").assertCountEquals(3) } @Test @@ -225,7 +252,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } // Wait for the UI to settle @@ -263,8 +291,9 @@ class SearchWorkerResultScreenTest { searchViewModel, accountViewModel, userViewModel, - quickFixViewModel, - preferencesViewModel) + quickFixViewModel, + preferencesViewModel, + workerViewModel = workerViewModel) } // Verify that the filter icon button is displayed and has a click action composeTestRule @@ -284,7 +313,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Scroll through the LazyColumn and verify each profile result is displayed val workerProfilesList = composeTestRule.onNodeWithTag("worker_profiles_list") @@ -308,7 +339,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Perform click on the back button and verify goBack() is called composeTestRule.onNodeWithContentDescription("Back").performClick() @@ -325,7 +358,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Wait for the UI to settle @@ -354,7 +389,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Wait until the worker profiles are displayed @@ -380,7 +417,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Wait until the worker profiles are displayed @@ -409,7 +448,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Wait until the worker profiles are displayed @@ -446,7 +487,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Wait until the worker profiles are displayed @@ -481,7 +524,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Wait until the worker profiles are displayed @@ -517,7 +562,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Wait until the worker profiles are displayed @@ -555,7 +602,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Wait until the worker profiles are displayed @@ -585,7 +634,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Wait until the worker profiles are displayed @@ -672,7 +723,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Initially, all workers should be displayed @@ -764,7 +817,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } // Initially, all workers should be displayed @@ -850,13 +905,16 @@ class SearchWorkerResultScreenTest { // Set the composable content composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - quickFixViewModel, - preferencesViewModel) + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel, + workerViewModel = workerViewModel + ) + } // Initially, all workers should be displayed @@ -940,7 +998,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1007,7 +1067,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1074,7 +1136,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1143,7 +1207,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1170,7 +1236,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1219,7 +1287,9 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) + } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1279,7 +1349,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1332,7 +1403,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } // Initially, both workers should be displayed @@ -1415,7 +1487,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } composeTestRule.waitForIdle() @@ -1487,7 +1560,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } composeTestRule.waitForIdle() @@ -1570,7 +1644,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } composeTestRule.waitForIdle() @@ -1617,7 +1692,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } composeTestRule.waitForIdle() @@ -1649,7 +1725,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } composeTestRule.waitForIdle() @@ -1700,7 +1777,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1789,7 +1867,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } // Initially, all workers should be displayed @@ -1896,7 +1975,8 @@ class SearchWorkerResultScreenTest { accountViewModel, userViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } // Initially, both workers should be displayed @@ -2032,7 +2112,7 @@ class SearchWorkerResultScreenTest { userViewModel, quickFixViewModel, preferencesViewModel, - ) + workerViewModel = workerViewModel) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("worker_profiles_list").onChildren().assertCountEquals(3) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/tools/ai/QuickFixAIChatScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/tools/ai/QuickFixAIChatScreenTest.kt new file mode 100644 index 00000000..df067e13 --- /dev/null +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/tools/ai/QuickFixAIChatScreenTest.kt @@ -0,0 +1,84 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.arygm.quickfix.ui.tools.ai + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.arygm.quickfix.model.tools.ai.GeminiMessageModel +import com.arygm.quickfix.model.tools.ai.GeminiViewModel +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.tools.ai.QuickFixAIChatScreen +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QuickFixAIChatScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + private lateinit var mockViewModel: GeminiViewModel + + @Before + fun setup() { + // In a real scenario, you might want to mock the network calls in GeminiViewModel. + // For now, we'll just initialize it directly. If needed, you can use a testing double. + mockViewModel = + GeminiViewModel().apply { + // By default, messageList contains only the context message. + // This means the UI should show "How may I help?" prompt. + } + } + + @Test + fun initialPromptIsDisplayedWhenNoMessages() { + // Given the initial state with only the context message + composeTestRule.setContent { QuickFixAIChatScreen(viewModel = mockViewModel) } + + // Then the prompt "How may I help?" should be displayed + composeTestRule.onNodeWithText("How may I help?").assertIsDisplayed() + } + + @Test + fun userCanSendMessageAndSeeItInTheList() = runTest { + composeTestRule.setContent { QuickFixAIChatScreen(viewModel = mockViewModel) } + + // Initially, "How may I help?" is displayed + composeTestRule.onNodeWithText("How may I help?").assertIsDisplayed() + + // When user types a message and sends it + val userMessage = "I need someone to paint my living room." + composeTestRule.onNodeWithText("Describe your issue").performTextInput(userMessage) + + // Click the send icon (which has contentDescription "Send") + composeTestRule.onNodeWithTag("sendIcon").performClick() + + // After sending, the user's message should now appear in the conversation. + // Wait a moment for the coroutine and message update if necessary. + composeTestRule.onNodeWithText(userMessage).assertIsDisplayed() + } + + @Test + fun contextMessageIsNotRepeatedAfterClear() = runTest { + // Start with a message already sent + mockViewModel.messageList.add(GeminiMessageModel("Can you help me fix a leaky faucet?", "user")) + + composeTestRule.setContent { QuickFixAIChatScreen(viewModel = mockViewModel) } + + // Check that the user's message is displayed + composeTestRule.onNodeWithText("Can you help me fix a leaky faucet?").assertIsDisplayed() + + // Clear messages + mockViewModel.clearMessages() + + // After clearing, we should see the initial prompt again + composeTestRule.onNodeWithText("How may I help?").assertIsDisplayed() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4e0bdb70..2d337f90 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,6 +49,10 @@ + + \ No newline at end of file diff --git a/app/src/main/java/com/arygm/quickfix/model/account/Account.kt b/app/src/main/java/com/arygm/quickfix/model/account/Account.kt index 0b88cc60..aa97b907 100644 --- a/app/src/main/java/com/arygm/quickfix/model/account/Account.kt +++ b/app/src/main/java/com/arygm/quickfix/model/account/Account.kt @@ -10,4 +10,6 @@ data class Account( val birthDate: Timestamp, val isWorker: Boolean = false, val activeChats: List = emptyList(), + val profilePicture: String = + "https://example.com/default-profile-pic.jpg" // Default profile picture URL ) diff --git a/app/src/main/java/com/arygm/quickfix/model/account/AccountRepository.kt b/app/src/main/java/com/arygm/quickfix/model/account/AccountRepository.kt index e056e0e7..06a98544 100644 --- a/app/src/main/java/com/arygm/quickfix/model/account/AccountRepository.kt +++ b/app/src/main/java/com/arygm/quickfix/model/account/AccountRepository.kt @@ -1,5 +1,7 @@ package com.arygm.quickfix.model.account +import android.graphics.Bitmap + interface AccountRepository { fun init(onSuccess: () -> Unit) @@ -19,4 +21,11 @@ interface AccountRepository { ) fun getAccountById(uid: String, onSuccess: (Account?) -> Unit, onFailure: (Exception) -> Unit) + + fun uploadAccountImages( + accountId: String, + images: List, + onSuccess: (List) -> Unit, + onFailure: (Exception) -> Unit + ) } diff --git a/app/src/main/java/com/arygm/quickfix/model/account/AccountRepositoryFirestore.kt b/app/src/main/java/com/arygm/quickfix/model/account/AccountRepositoryFirestore.kt index bd6d1640..24dad56e 100644 --- a/app/src/main/java/com/arygm/quickfix/model/account/AccountRepositoryFirestore.kt +++ b/app/src/main/java/com/arygm/quickfix/model/account/AccountRepositoryFirestore.kt @@ -1,15 +1,22 @@ package com.arygm.quickfix.model.account +import android.graphics.Bitmap import android.util.Log import com.arygm.quickfix.utils.performFirestoreOperation import com.google.firebase.Firebase import com.google.firebase.auth.auth import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.storage.FirebaseStorage +import java.io.ByteArrayOutputStream -open class AccountRepositoryFirestore(private val db: FirebaseFirestore) : AccountRepository { +open class AccountRepositoryFirestore( + private val db: FirebaseFirestore, + private val storage: FirebaseStorage +) : AccountRepository { private val collectionPath = "accounts" + private val storageRef = storage.reference override fun init(onSuccess: () -> Unit) { Firebase.auth.addAuthStateListener { @@ -96,6 +103,8 @@ open class AccountRepositoryFirestore(private val db: FirebaseFirestore) : Accou val isWorker = document.getBoolean("worker") ?: return null val activeChats = document.get("activeChats") as? List ?: emptyList() Log.d("AccountRepositoryFirestore", "isWorker: $isWorker") + val profilePicture = document.getString("profilePicture") ?: "" + Log.d("AccountRepositoryFirestore", "profilePicture: $profilePicture") val account = Account( @@ -105,7 +114,9 @@ open class AccountRepositoryFirestore(private val db: FirebaseFirestore) : Accou email = email, birthDate = birthDate, isWorker = isWorker, - activeChats = activeChats) + activeChats = activeChats, + profilePicture = profilePicture) + Log.d("AccountRepositoryFirestore", "account: $account") account } catch (e: Exception) { @@ -136,4 +147,38 @@ open class AccountRepositoryFirestore(private val db: FirebaseFirestore) : Accou onFailure(exception) } } + + override fun uploadAccountImages( + accountId: String, + images: List, + onSuccess: (List) -> Unit, + onFailure: (Exception) -> Unit + ) { + val accountFolderRef = storageRef.child("accounts").child(accountId) + val uploadedImageUrls = mutableListOf() + var uploadCount = 0 + + images.forEach { bitmap -> + val fileRef = accountFolderRef.child("image_${System.currentTimeMillis()}.jpg") + + val baos = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, baos) // Compression qualité 90 + val byteArray = baos.toByteArray() + + fileRef + .putBytes(byteArray) + .addOnSuccessListener { + fileRef.downloadUrl + .addOnSuccessListener { uri -> + uploadedImageUrls.add(uri.toString()) + uploadCount++ + if (uploadCount == images.size) { + onSuccess(uploadedImageUrls) + } + } + .addOnFailureListener { exception -> onFailure(exception) } + } + .addOnFailureListener { exception -> onFailure(exception) } + } + } } diff --git a/app/src/main/java/com/arygm/quickfix/model/account/AccountViewModel.kt b/app/src/main/java/com/arygm/quickfix/model/account/AccountViewModel.kt index b93f7abd..e22955e1 100644 --- a/app/src/main/java/com/arygm/quickfix/model/account/AccountViewModel.kt +++ b/app/src/main/java/com/arygm/quickfix/model/account/AccountViewModel.kt @@ -1,10 +1,12 @@ package com.arygm.quickfix.model.account +import android.graphics.Bitmap import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.google.firebase.Firebase import com.google.firebase.firestore.firestore +import com.google.firebase.storage.storage import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,7 +25,9 @@ open class AccountViewModel(private val repository: AccountRepository) : ViewMod object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return AccountViewModel(AccountRepositoryFirestore(Firebase.firestore)) as T + return AccountViewModel( + AccountRepositoryFirestore(Firebase.firestore, Firebase.storage)) + as T } } } @@ -102,4 +106,15 @@ open class AccountViewModel(private val repository: AccountRepository) : ViewMod onResult(null) }) } + + fun uploadAccountImages( + accountId: String, + images: List, + onSuccess: (List) -> Unit, + onFailure: (Exception) -> Unit + ) { + Log.d("UploadingAccountImages", "Uploading ${images.size} images for account: $accountId") + repository.uploadAccountImages( + accountId = accountId, images = images, onSuccess = onSuccess, onFailure = onFailure) + } } diff --git a/app/src/main/java/com/arygm/quickfix/model/offline/small/PreferencesViewModel.kt b/app/src/main/java/com/arygm/quickfix/model/offline/small/PreferencesViewModel.kt index 38160898..92ecd622 100644 --- a/app/src/main/java/com/arygm/quickfix/model/offline/small/PreferencesViewModel.kt +++ b/app/src/main/java/com/arygm/quickfix/model/offline/small/PreferencesViewModel.kt @@ -11,6 +11,7 @@ import com.arygm.quickfix.utils.FIRST_NAME_KEY import com.arygm.quickfix.utils.IS_SIGN_IN_KEY import com.arygm.quickfix.utils.IS_WORKER_KEY import com.arygm.quickfix.utils.LAST_NAME_KEY +import com.arygm.quickfix.utils.PROFILE_PICTURE_KEY import com.arygm.quickfix.utils.UID_KEY import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -38,6 +39,8 @@ open class PreferencesViewModel( val birthDate: Flow = repositoryAccount.getPreferenceByKey(BIRTH_DATE_KEY).map { it ?: "" } + val profilePicture: Flow = + repositoryAccount.getPreferenceByKey(PROFILE_PICTURE_KEY).map { it ?: "" } val isSignInKey: Flow = repositoryAccount.getPreferenceByKey(IS_SIGN_IN_KEY).map { it ?: false } diff --git a/app/src/main/java/com/arygm/quickfix/model/profile/Profile.kt b/app/src/main/java/com/arygm/quickfix/model/profile/Profile.kt index 6bab55d0..2879af50 100644 --- a/app/src/main/java/com/arygm/quickfix/model/profile/Profile.kt +++ b/app/src/main/java/com/arygm/quickfix/model/profile/Profile.kt @@ -24,12 +24,14 @@ open class Profile( } class UserProfile( + // String of uid that will represents the uid of the saved lists val locations: List, val announcements: List, // Each string correspond to an announcement id. val wallet: Double = 0.0, uid: String, quickFixes: List = emptyList(), // String of uid that will represents the uid of the QuickFixes + val savedList: List = emptyList(), ) : Profile(uid, quickFixes) { // quickFixes: List, // String of uid that will represents the uid of the QuickFixes override fun equals(other: Any?): Boolean { @@ -37,11 +39,36 @@ class UserProfile( if (other !is UserProfile) return false if (!super.equals(other)) return false - return locations == other.locations + return locations == other.locations && + announcements == other.announcements && + wallet == other.wallet && + savedList == other.savedList } override fun hashCode(): Int { - return listOf(super.hashCode(), locations).hashCode() + var result = super.hashCode() + result = 31 * result + locations.hashCode() + result = 31 * result + announcements.hashCode() + result = 31 * result + wallet.hashCode() + result = 31 * result + savedList.hashCode() + return result + } + + fun copy( + locations: List = this.locations, + announcements: List = this.announcements, + wallet: Double = this.wallet, + uid: String = this.uid, + quickFixes: List = this.quickFixes, + savedList: List = this.savedList + ): UserProfile { + return UserProfile( + locations = locations, + announcements = announcements, + wallet = wallet, + uid = uid, + quickFixes = quickFixes, + savedList = savedList) } } @@ -83,11 +110,36 @@ class WorkerProfile( return fieldOfWork == other.fieldOfWork && description == other.description && location == other.location && - reviews == other.reviews + includedServices == other.includedServices && + addOnServices == other.addOnServices && + reviews == other.reviews && + profilePicture == other.profilePicture && + bannerPicture == other.bannerPicture && + price == other.price && + displayName == other.displayName && + unavailability_list == other.unavailability_list && + workingHours == other.workingHours && + tags == other.tags && + rating == other.rating } override fun hashCode(): Int { - return listOf(super.hashCode(), fieldOfWork, description, location, reviews).hashCode() + var result = super.hashCode() + result = 31 * result + fieldOfWork.hashCode() + result = 31 * result + description.hashCode() + result = 31 * result + (location?.hashCode() ?: 0) + result = 31 * result + includedServices.hashCode() + result = 31 * result + addOnServices.hashCode() + result = 31 * result + reviews.hashCode() + result = 31 * result + profilePicture.hashCode() + result = 31 * result + bannerPicture.hashCode() + result = 31 * result + price.hashCode() + result = 31 * result + displayName.hashCode() + result = 31 * result + unavailability_list.hashCode() + result = 31 * result + workingHours.hashCode() + result = 31 * result + tags.hashCode() + result = 31 * result + rating.hashCode() + return result } fun toFirestoreMap(): Map { diff --git a/app/src/main/java/com/arygm/quickfix/model/profile/ProfileRepository.kt b/app/src/main/java/com/arygm/quickfix/model/profile/ProfileRepository.kt index 299a8d11..d7b3d751 100644 --- a/app/src/main/java/com/arygm/quickfix/model/profile/ProfileRepository.kt +++ b/app/src/main/java/com/arygm/quickfix/model/profile/ProfileRepository.kt @@ -31,4 +31,16 @@ interface ProfileRepository { onSuccess: (List) -> Unit, onFailure: (Exception) -> Unit ) + + fun fetchProfileImageAsBitmap( + accountId: String, + onSuccess: (Bitmap) -> Unit, + onFailure: (Exception) -> Unit + ) + + fun fetchBannerImageAsBitmap( + accountId: String, + onSuccess: (Bitmap) -> Unit, + onFailure: (Exception) -> Unit + ) } diff --git a/app/src/main/java/com/arygm/quickfix/model/profile/ProfileViewModel.kt b/app/src/main/java/com/arygm/quickfix/model/profile/ProfileViewModel.kt index 5e79625e..9db001cf 100644 --- a/app/src/main/java/com/arygm/quickfix/model/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/arygm/quickfix/model/profile/ProfileViewModel.kt @@ -66,7 +66,6 @@ open class ProfileViewModel(private val repository: ProfileRepository) : ViewMod profile = profile, onSuccess = { getProfiles() - onSuccess() // fetchUserProfile(profile.uid) { loggedInProfileViewModel.setLoggedInProfile(profile) } }, @@ -113,4 +112,20 @@ open class ProfileViewModel(private val repository: ProfileRepository) : ViewMod onSuccess = { onSuccess(it) }, onFailure = { e -> onFailure(e) }) } + + fun fetchProfileImageAsBitmap( + accountId: String, + onSuccess: (Bitmap) -> Unit, + onFailure: (Exception) -> Unit + ) { + repository.fetchProfileImageAsBitmap(accountId, onSuccess, onFailure) + } + + fun fetchBannerImageAsBitmap( + accountId: String, + onSuccess: (Bitmap) -> Unit, + onFailure: (Exception) -> Unit + ) { + repository.fetchBannerImageAsBitmap(accountId, onSuccess, onFailure) + } } diff --git a/app/src/main/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestore.kt b/app/src/main/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestore.kt index 57042b56..afaf7a99 100644 --- a/app/src/main/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestore.kt +++ b/app/src/main/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestore.kt @@ -1,6 +1,7 @@ package com.arygm.quickfix.model.profile import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.util.Log import com.arygm.quickfix.model.locations.Location import com.google.android.gms.tasks.Task @@ -102,6 +103,116 @@ open class UserProfileRepositoryFirestore( } } + private fun fetchProfileImageUrl( + accountId: String, + onSuccess: (String) -> Unit, + onFailure: (Exception) -> Unit, + documentId: String + ) { + val firestore = db + val collection = firestore.collection(collectionPath) + collection + .document(accountId) + .get() + .addOnSuccessListener { document -> + val imageUrl = document[documentId] as? String ?: "" + onSuccess(imageUrl) + } + .addOnFailureListener { onFailure(it) } + } + + override fun fetchProfileImageAsBitmap( + accountId: String, + onSuccess: (Bitmap) -> Unit, + onFailure: (Exception) -> Unit + ) { + fetchProfileImageUrl( + accountId, + { url -> + if (url.isEmpty()) { + val defaultBannerBitmap = + createSolidColorBitmap( + width = 800, // Adjust the width in pixels + height = 400, // Adjust the height in pixels + color = 0xFF66001A.toInt()) + onSuccess(defaultBannerBitmap) + } else { + if (url.isEmpty() || url.contains("10.0.2.2:9199")) { + Log.d( + "WorkerProfileRepositoryFirestore", + "No profile image found for account ID: $accountId") + val defaultProfileBitmap = + createSolidColorBitmap( + width = 200, // Adjust the width in pixels + height = 200, // Adjust the height in pixels + color = 0xFF66001A.toInt()) + onSuccess(defaultProfileBitmap) + } else { + Log.d("WorkerProfileRepositoryFirestore", "Fetching profile image from URL: $url") + val imageRef = storage.getReferenceFromUrl(url) + imageRef + .getBytes(Long.MAX_VALUE) + .addOnSuccessListener { bytes -> + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + onSuccess(bitmap) + } + .addOnFailureListener { + Log.e("WorkerProfileRepositoryFirestore", "Failed to fetch profile image", it) + onFailure(it) + } + } + } + }, + onFailure, + "profileImageUrl") + } + + override fun fetchBannerImageAsBitmap( + accountId: String, + onSuccess: (Bitmap) -> Unit, + onFailure: (Exception) -> Unit + ) { + fetchProfileImageUrl( + accountId, + { url -> + if (url.isEmpty()) { + val defaultBannerBitmap = + createSolidColorBitmap( + width = 800, // Adjust the width in pixels + height = 400, // Adjust the height in pixels + color = 0xFF66001A.toInt()) + onSuccess(defaultBannerBitmap) + } else { + if (url.isEmpty() || url.contains("10.0.2.2:9199")) { + Log.d( + "WorkerProfileRepositoryFirestore", + "No profile image found for account ID: $accountId") + val defaultProfileBitmap = + createSolidColorBitmap( + width = 200, // Adjust the width in pixels + height = 200, // Adjust the height in pixels + color = 0xFF66001A.toInt()) + onSuccess(defaultProfileBitmap) + } else { + Log.d("WorkerProfileRepositoryFirestore", "Fetching profile image from URL: $url") + val imageRef = storage.getReferenceFromUrl(url) + imageRef + .getBytes(Long.MAX_VALUE) + .addOnSuccessListener { bytes -> + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + onSuccess(bitmap) + } + .addOnFailureListener { + Log.e("WorkerProfileRepositoryFirestore", "Failed to fetch profile image", it) + onFailure(it) + } + } + } + }, + onFailure, + "bannerImageUrl") + } + private fun performFirestoreOperation( task: Task, onSuccess: () -> Unit, diff --git a/app/src/main/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestore.kt b/app/src/main/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestore.kt index 6aeceaa0..f9965bdc 100644 --- a/app/src/main/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestore.kt +++ b/app/src/main/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestore.kt @@ -1,6 +1,9 @@ package com.arygm.quickfix.model.profile import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint import android.util.Log import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.dataFields.AddOnService @@ -11,12 +14,10 @@ import com.google.firebase.Firebase import com.google.firebase.auth.auth import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore -import com.google.firebase.firestore.Query import com.google.firebase.storage.FirebaseStorage import java.io.ByteArrayOutputStream import java.time.LocalDate import java.time.LocalTime -import kotlin.math.cos open class WorkerProfileRepositoryFirestore( private val db: FirebaseFirestore, @@ -234,23 +235,25 @@ open class WorkerProfileRepositoryFirestore( val profilePicture = document.getString("profileImageUrl") ?: "" val bannerPicture = document.getString("bannerImageUrl") ?: "" val quickFixes = document.get("quickFixes") as? List ?: emptyList() - - WorkerProfile( - uid = uid, - price = price, - description = description, - fieldOfWork = fieldOfWork, - location = location, - unavailability_list = unavailabilityList, - workingHours = workingHours, - reviews = reviews.toCollection(ArrayDeque()), - includedServices = includedServices, - addOnServices = addOnServices, - profilePicture = profilePicture, - bannerPicture = bannerPicture, - displayName = displayName, - tags = tags, - quickFixes = quickFixes) + val workerProfile = + WorkerProfile( + uid = uid, + price = price, + description = description, + fieldOfWork = fieldOfWork, + location = location, + unavailability_list = unavailabilityList, + workingHours = workingHours, + reviews = reviews.toCollection(ArrayDeque()), + includedServices = includedServices, + addOnServices = addOnServices, + profilePicture = profilePicture, + bannerPicture = bannerPicture, + displayName = displayName, + tags = tags, + quickFixes = quickFixes) + Log.d("WorkerProfileRepositoryFirestore", workerProfile.toString()) + workerProfile } catch (e: Exception) { Log.e("WorkerProfileRepositoryFirestore", "Error converting document to WorkerProfile", e) null @@ -279,54 +282,121 @@ open class WorkerProfileRepositoryFirestore( } } - fun filterWorkers( - rating: Double?, - reviews: List?, - price: Double?, - fieldOfWork: String?, - location: Location?, - radiusInKm: Double?, - onSuccess: (List) -> Unit, - onFailure: (Exception) -> Unit + private fun fetchProfileImageUrl( + accountId: String, + onSuccess: (String) -> Unit, + onFailure: (Exception) -> Unit, + documentId: String ) { - var query: Query = db.collection(collectionPath) - - rating?.let { query = query.whereEqualTo("rating", it) } - reviews?.takeIf { it.isNotEmpty() }?.let { query = query.whereArrayContainsAny("reviews", it) } - - fieldOfWork?.let { query = query.whereEqualTo("fieldOfWork", it) } - - price?.let { query = query.whereLessThan("price", it) } - - if (location != null && radiusInKm != null) { - val earthRadius = 6371.0 - val lat = location.latitude - val lon = location.longitude - val latDelta = radiusInKm / earthRadius - val lonDelta = radiusInKm / (earthRadius * cos(Math.toRadians(lat))) - - val minLat = lat - Math.toDegrees(latDelta) - val maxLat = lat + Math.toDegrees(latDelta) - val minLon = lon - Math.toDegrees(lonDelta) - val maxLon = lon + Math.toDegrees(lonDelta) - - // Add range filters for latitude and longitude - query = - query - .whereGreaterThanOrEqualTo("location.latitude", minLat) - .whereLessThanOrEqualTo("location.latitude", maxLat) - .whereGreaterThanOrEqualTo("location.longitude", minLon) - .whereLessThanOrEqualTo("location.longitude", maxLon) - } - query + val firestore = db + val collection = firestore.collection(collectionPath) + collection + .document(accountId) .get() - .addOnSuccessListener { querySnapshot -> - Log.d( - "WorkerProfileRepositoryFirestore", - "Successfully fetched worker profiles : ${querySnapshot.documents.size}") - val workerProfiles = querySnapshot.documents.mapNotNull { documentToWorker(it) } - onSuccess(workerProfiles) + .addOnSuccessListener { document -> + val imageUrl = document[documentId] as? String ?: "" + onSuccess(imageUrl) } - .addOnFailureListener { exception -> onFailure(exception) } + .addOnFailureListener { onFailure(it) } } + + override fun fetchProfileImageAsBitmap( + accountId: String, + onSuccess: (Bitmap) -> Unit, + onFailure: (Exception) -> Unit + ) { + fetchProfileImageUrl( + accountId, + { url -> + if (url.isEmpty()) { + val defaultBannerBitmap = + createSolidColorBitmap( + width = 800, // Adjust the width in pixels + height = 400, // Adjust the height in pixels + color = 0xFF66001A.toInt()) + onSuccess(defaultBannerBitmap) + } else { + if (url.isEmpty() || url.contains("10.0.2.2:9199")) { + Log.d( + "WorkerProfileRepositoryFirestore", + "No profile image found for account ID: $accountId") + val defaultProfileBitmap = + createSolidColorBitmap( + width = 200, // Adjust the width in pixels + height = 200, // Adjust the height in pixels + color = 0xFF66001A.toInt()) + onSuccess(defaultProfileBitmap) + } else { + Log.d("WorkerProfileRepositoryFirestore", "Fetching profile image from URL: $url") + val imageRef = storage.getReferenceFromUrl(url) + imageRef + .getBytes(Long.MAX_VALUE) + .addOnSuccessListener { bytes -> + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + onSuccess(bitmap) + } + .addOnFailureListener { + Log.e("WorkerProfileRepositoryFirestore", "Failed to fetch profile image", it) + onFailure(it) + } + } + } + }, + onFailure, + "profileImageUrl") + } + + override fun fetchBannerImageAsBitmap( + accountId: String, + onSuccess: (Bitmap) -> Unit, + onFailure: (Exception) -> Unit + ) { + fetchProfileImageUrl( + accountId, + { url -> + if (url.isEmpty()) { + val defaultBannerBitmap = + createSolidColorBitmap( + width = 800, // Adjust the width in pixels + height = 400, // Adjust the height in pixels + color = 0xFF66001A.toInt()) + onSuccess(defaultBannerBitmap) + } else { + if (url.isEmpty() || url.contains("10.0.2.2:9199")) { + Log.d( + "WorkerProfileRepositoryFirestore", + "No profile image found for account ID: $accountId") + val defaultProfileBitmap = + createSolidColorBitmap( + width = 200, // Adjust the width in pixels + height = 200, // Adjust the height in pixels + color = 0xFF66001A.toInt()) + onSuccess(defaultProfileBitmap) + } else { + Log.d("WorkerProfileRepositoryFirestore", "Fetching profile image from URL: $url") + val imageRef = storage.getReferenceFromUrl(url) + imageRef + .getBytes(Long.MAX_VALUE) + .addOnSuccessListener { bytes -> + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + onSuccess(bitmap) + } + .addOnFailureListener { + Log.e("WorkerProfileRepositoryFirestore", "Failed to fetch profile image", it) + onFailure(it) + } + } + } + }, + onFailure, + "bannerImageUrl") + } +} + +fun createSolidColorBitmap(width: Int, height: Int, color: Int): Bitmap { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint().apply { this.color = color } + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + return bitmap } diff --git a/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementRepository.kt b/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementRepository.kt index c0dc528a..d1350c99 100644 --- a/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementRepository.kt +++ b/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementRepository.kt @@ -15,6 +15,12 @@ interface AnnouncementRepository { onFailure: (Exception) -> Unit ) + fun getAnnouncementsByCategory( + category: String, + onSuccess: (List) -> Unit, + onFailure: (Exception) -> Unit + ) + fun announce(announcement: Announcement, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) fun uploadAnnouncementImages( diff --git a/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementRepositoryFirestore.kt b/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementRepositoryFirestore.kt index 54323551..5ca740f6 100644 --- a/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementRepositoryFirestore.kt +++ b/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementRepositoryFirestore.kt @@ -78,6 +78,22 @@ class AnnouncementRepositoryFirestore( } } + override fun getAnnouncementsByCategory( + category: String, + onSuccess: (List) -> Unit, + onFailure: (Exception) -> Unit + ) { + db.collection(collectionPath) + .whereEqualTo("category", category) // Query for matching category + .get() + .addOnSuccessListener { querySnapshot -> + val announcements = + querySnapshot.documents.mapNotNull { document -> documentToAnnouncement(document) } + onSuccess(announcements) + } + .addOnFailureListener { exception -> onFailure(exception) } + } + override fun announce( announcement: Announcement, onSuccess: () -> Unit, diff --git a/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementViewModel.kt b/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementViewModel.kt index d7a4a56c..785cc901 100644 --- a/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementViewModel.kt +++ b/app/src/main/java/com/arygm/quickfix/model/search/AnnouncementViewModel.kt @@ -5,10 +5,18 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.offline.small.PreferencesRepository import com.arygm.quickfix.model.profile.ProfileRepository import com.arygm.quickfix.model.profile.UserProfile +import com.arygm.quickfix.model.profile.UserProfileRepositoryFirestore +import com.arygm.quickfix.model.profile.WorkerProfile +import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.utils.UID_KEY +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,7 +25,7 @@ import kotlinx.coroutines.launch open class AnnouncementViewModel( private val announcementRepository: AnnouncementRepository, private val preferencesRepository: PreferencesRepository, - private val userProfileRepository: ProfileRepository + private val profileRepository: ProfileRepository, ) : ViewModel() { private val announcementsForUser_ = MutableStateFlow>(emptyList()) @@ -38,15 +46,20 @@ open class AnnouncementViewModel( val selectedAnnouncement: StateFlow = selectedAnnouncement_.asStateFlow() init { - announcementRepository.init { getAnnouncementsForCurrentUser() } + if (profileRepository is UserProfileRepositoryFirestore) { + announcementRepository.init { getAnnouncementsForCurrentUser() } + } + if (profileRepository is WorkerProfileRepositoryFirestore) { + announcementRepository.init { getAnnouncementsForCurrentWorker() } + } } // create factory companion object { - fun Factory( + fun userFactory( announcementRepository: AnnouncementRepository, preferencesRepository: PreferencesRepository, - userProfileRepository: ProfileRepository + userProfileRepository: UserProfileRepositoryFirestore ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -57,6 +70,21 @@ open class AnnouncementViewModel( as T } } + + fun workerFactory( + announcementRepository: AnnouncementRepository, + preferencesRepository: PreferencesRepository, + workerProfileRepository: WorkerProfileRepositoryFirestore + ): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + + return AnnouncementViewModel( + announcementRepository, preferencesRepository, workerProfileRepository) + as T + } + } } /** @@ -94,6 +122,17 @@ open class AnnouncementViewModel( }) } + /** Fetches announcements by category. */ + fun getAnnouncementsByCategory(category: String) { + announcementRepository.getAnnouncementsByCategory( + category, + onSuccess = { filteredAnnouncements -> + announcements_.value = + filteredAnnouncements // Update announcements with the filtered list + }, + onFailure = { e -> Log.e("Failed to fetch announcements by category", e.toString()) }) + } + fun getAnnouncementsForCurrentUser() { viewModelScope.launch { try { @@ -105,7 +144,7 @@ open class AnnouncementViewModel( } // Step 2: Fetch the user profile using the user ID - userProfileRepository.getProfileById( + profileRepository.getProfileById( uid = userId, onSuccess = { profile -> if (profile is UserProfile) { @@ -128,6 +167,38 @@ open class AnnouncementViewModel( } } + fun getAnnouncementsForCurrentWorker() { + viewModelScope.launch { + try { + // Step 1: Load the user ID from preferences + preferencesRepository.getPreferenceByKey(UID_KEY).collect { userId -> + if (userId.isNullOrEmpty()) { + Log.e("AnnouncementViewModel", "Failed to load user ID") + return@collect + } + + // Step 2: Fetch the user profile using the user ID + profileRepository.getProfileById( + uid = userId, + onSuccess = { profile -> + if (profile is WorkerProfile) { + Log.d("AnnouncementViewModel", profile.fieldOfWork) + // Step 3: Fetch all announcements with the same category field + getAnnouncementsByCategory(profile.fieldOfWork) + } else { + Log.e("AnnouncementViewModel", "Not a worker profile found for user ID: $userId") + } + }, + onFailure = { e -> + Log.e("AnnouncementViewModel", "Error fetching profile for user ID: $userId", e) + }) + } + } catch (e: Exception) { + Log.e("AnnouncementViewModel", "Error getting announcements for current user", e) + } + } + } + /** * Adds an announcement. * @@ -225,7 +296,7 @@ open class AnnouncementViewModel( } // Fetch the user profile - userProfileRepository.getProfileById( + profileRepository.getProfileById( uid = userId, onSuccess = { profile -> if (profile is UserProfile) { @@ -243,7 +314,7 @@ open class AnnouncementViewModel( profile.quickFixes) // Update the profile - userProfileRepository.updateProfile( + this@AnnouncementViewModel.profileRepository.updateProfile( profile = updatedProfile, onSuccess = { // Remove the announcement from the local cached lists @@ -333,4 +404,39 @@ open class AnnouncementViewModel( fun setAnnouncementImagesMap(updatedMap: MutableMap>>) { announcementImagesMap_.value = updatedMap } + + /** + * updates the map between announcements and images. + * + * @param announcements The announcements to filter. + */ + fun filterAnnouncementsByDistance( + announcements: List, + userLocation: Location, + maxDistance: Int + ): List { + return announcements.filter { announcement -> + val distance = + calculateDistance( + userLocation.latitude, + userLocation.longitude, + announcement.location!!.latitude, + announcement.location.longitude) + distance <= maxDistance + } + } + + /** Calculates the distance between two distinct locations */ + fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val earthRadius = 6371.0 // Radius of the Earth in kilometers + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + + val a = + sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2) + + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return earthRadius * c + } } diff --git a/app/src/main/java/com/arygm/quickfix/model/search/SearchViewModel.kt b/app/src/main/java/com/arygm/quickfix/model/search/SearchViewModel.kt index 0a750139..3170a533 100644 --- a/app/src/main/java/com/arygm/quickfix/model/search/SearchViewModel.kt +++ b/app/src/main/java/com/arygm/quickfix/model/search/SearchViewModel.kt @@ -3,7 +3,6 @@ package com.arygm.quickfix.model.search import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope import com.arygm.quickfix.model.category.Category import com.arygm.quickfix.model.category.Subcategory import com.arygm.quickfix.model.locations.Location @@ -20,7 +19,6 @@ import kotlin.math.sin import kotlin.math.sqrt import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch open class SearchViewModel( private val workerProfileRepo: WorkerProfileRepositoryFirestore, @@ -77,50 +75,6 @@ open class SearchViewModel( _workerProfiles.value = workerProfiles } - fun updateSearchQuery(query: String) { - viewModelScope.launch { - _searchQuery.value = query - filterWorkerProfiles(fieldOfWork = query) - } - } - - fun filterWorkerProfiles( - rating: Double? = null, - reviews: List? = emptyList(), - price: Double? = null, - fieldOfWork: String? = null, - location: Location? = null, - maxDistanceInKm: Double? = null - ) { - val userLat = location?.latitude - val userLon = location?.longitude - - workerProfileRepo.filterWorkers( - rating, - reviews, - price, - fieldOfWork, - location, - maxDistanceInKm, - { profiles -> - if (userLat != null && userLon != null && maxDistanceInKm != null) { - val filteredProfiles = - profiles.filter { profile -> - val location = - profile.location ?: return@filter false // Ensure location is non-null - val workerLat = location.latitude - val workerLon = location.longitude - val distance = calculateDistance(userLat, userLon, workerLat, workerLon) - distance <= maxDistanceInKm - } - _workerProfiles.value = filteredProfiles - } else { - _workerProfiles.value = profiles - } - }, - { error -> _errorMessage.value = error.message }) - } - fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val earthRadius = 6371.0 // Radius of the Earth in kilometers val dLat = Math.toRadians(lat2 - lat1) diff --git a/app/src/main/java/com/arygm/quickfix/model/tools/ai/GeminiMessageModel.kt b/app/src/main/java/com/arygm/quickfix/model/tools/ai/GeminiMessageModel.kt new file mode 100644 index 00000000..8fbf6edd --- /dev/null +++ b/app/src/main/java/com/arygm/quickfix/model/tools/ai/GeminiMessageModel.kt @@ -0,0 +1,3 @@ +package com.arygm.quickfix.model.tools.ai + +data class GeminiMessageModel(val message: String, val role: String) diff --git a/app/src/main/java/com/arygm/quickfix/model/tools/ai/GeminiViewModel.kt b/app/src/main/java/com/arygm/quickfix/model/tools/ai/GeminiViewModel.kt new file mode 100644 index 00000000..34889405 --- /dev/null +++ b/app/src/main/java/com/arygm/quickfix/model/tools/ai/GeminiViewModel.kt @@ -0,0 +1,102 @@ +package com.arygm.quickfix.model.tools.ai + +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.arygm.quickfix.BuildConfig.GEMINI_API_KEY +import com.google.ai.client.generativeai.GenerativeModel +import com.google.ai.client.generativeai.type.content +import kotlinx.coroutines.launch + +class GeminiViewModel( + private val generativeModel: GenerativeModel = + GenerativeModel(modelName = "gemini-pro", apiKey = GEMINI_API_KEY) +) : ViewModel() { + + val contextMessage = + """ +You are an assistant for the Quickfix app, which helps users find skilled professionals for various tasks. Your main purpose is to understand each user’s problem—whether it’s broadly or narrowly described—and help them identify the best category and subcategory of services within the app. Once the user’s need is identified, you will suggest the appropriate category and subcategory that matches their request. If the user’s request is unclear, you should ask clarifying questions. If multiple categories or subcategories could fit, ask questions to narrow down the options. If multiple categories are applicable to the job, you should suggest all relevant categories and explain how they relate to the user's request. + +When processing a user request: +1. Consider the broad description of their problem and attempt to map it to one of the main categories. +2. If the user gives more detail, use that information to select the most fitting subcategory. +3. If the user’s request matches multiple subcategories, guide them by asking about specifics of their situation to help refine the choice. +4. If multiple categories are applicable, present all relevant options to the user and provide a brief explanation for each. +5. If the user is completely unsure, offer them examples of what each category/subcategory covers to help them decide. + +Once the appropriate category or categories have been determined, invite the user to navigate to the SearchScreen to find their next QuickFix professional. + +Below are the available categories and their subcategories, each representing a specialized set of skills and services: + +- Painting: + - Residential Painting + - Commercial Painting + - Decorative Painting + +- Plumbing: + - Residential Plumbing + - Commercial Plumbing + +- Gardening: + - Landscaping + - Maintenance + +- Electrical Work: + - Residential Electrical Services + - Commercial Electrical Services + +- Handyman Services: + - General Repairs + - Home Maintenance + +- Cleaning Services: + - Residential Cleaning + - Commercial Cleaning + +- Carpentry: + - Furniture Carpentry + - Construction Carpentry + +- Moving Services: + - Local Moving + - Long Distance Moving + +Each category and subcategory includes specific services, pricing scales, and tags that represent the typical tasks provided. Use these details to guide the user accurately. Your response should be friendly, concise, and helpful. Feel free to ask follow-up questions if you need more information to make a correct suggestion. + +Your role: +- Parse the user’s description (broad or detailed). +- Identify which category and subcategory best fits. +- If uncertain, ask for clarification or present options to the user. +- If multiple categories or subcategories apply, suggest all relevant ones and provide a brief explanation. +- Once the categories have been determined, invite the user to navigate to the search screen to find their next QuickFix. + +By following these guidelines, you will assist users in quickly finding the most suitable professional service within the Quickfix app. +""" + + val messageList by lazy { mutableStateListOf(GeminiMessageModel(contextMessage, "user")) } + + fun sendMessage(question: String) { + viewModelScope.launch { + try { + val chat = + generativeModel.startChat( + history = messageList.map { content(it.role) { text(it.message) } }.toList()) + + messageList.add(GeminiMessageModel(question, "user")) + messageList.add(GeminiMessageModel("•••", "model")) + + val response = chat.sendMessage(question) + messageList.removeAt(messageList.lastIndex) + messageList.add(GeminiMessageModel(response.text.toString(), "model")) + } catch (e: Exception) { + messageList.removeAt(messageList.lastIndex) + messageList.add(GeminiMessageModel("Error : " + e.message.toString(), "model")) + } + } + } + + fun clearMessages() { + messageList.clear() // Clear all current messages + messageList.add(GeminiMessageModel(contextMessage, "user")) // Add back the context message + } +} diff --git a/app/src/main/java/com/arygm/quickfix/ui/dashboard/AnnouncementsWidget.kt b/app/src/main/java/com/arygm/quickfix/ui/dashboard/AnnouncementsWidget.kt index c221f275..1ac16964 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/dashboard/AnnouncementsWidget.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/dashboard/AnnouncementsWidget.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme.colorScheme @@ -38,13 +40,11 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel -import com.arygm.quickfix.R import com.arygm.quickfix.model.category.Category import com.arygm.quickfix.model.category.CategoryViewModel import com.arygm.quickfix.model.category.getCategoryIcon @@ -168,15 +168,15 @@ fun AnnouncementItem( .background(colorScheme.onSurface.copy(alpha = 0.1f)) .testTag("AnnouncementImage_${announcement.announcementId}")) } else { - Image( - painter = painterResource(id = R.drawable.placeholder_worker), - contentDescription = "Placeholder Image", - contentScale = ContentScale.Crop, + Box( modifier = Modifier.size(40.dp) .clip(RoundedCornerShape(8.dp)) - .background(colorScheme.onSurface.copy(alpha = 0.1f)) - .testTag("PlaceholderImage_${announcement.announcementId}")) + .background(colorScheme.onSurface.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = colorScheme.primary, modifier = Modifier.testTag("Loader")) + } } Spacer(modifier = Modifier.width(8.dp)) diff --git a/app/src/main/java/com/arygm/quickfix/ui/elements/QuickFixLocationFilterBottomSheet.kt b/app/src/main/java/com/arygm/quickfix/ui/elements/QuickFixLocationFilterBottomSheet.kt index 1e542ca9..2f99abdc 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/elements/QuickFixLocationFilterBottomSheet.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/elements/QuickFixLocationFilterBottomSheet.kt @@ -41,14 +41,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arygm.quickfix.model.locations.Location +import com.arygm.quickfix.model.profile.Profile import com.arygm.quickfix.model.profile.UserProfile +import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.ui.theme.poppinsTypography @OptIn(ExperimentalMaterial3Api::class) @Composable fun QuickFixLocationFilterBottomSheet( showModalBottomSheet: Boolean, - userProfile: UserProfile, + profile: Profile, phoneLocation: Location, selectedLocationIndex: Int? = null, onApplyClick: (Location, Int) -> Unit, @@ -97,7 +99,12 @@ fun QuickFixLocationFilterBottomSheet( Spacer(modifier = Modifier.height(verticalSpacing)) val locationOptions = mutableListOf("Use my Current Location") - userProfile.locations.forEach { loc -> locationOptions += loc.name } + if (profile is UserProfile) { + profile.locations.forEach { loc -> locationOptions += loc.name } + } else if (profile is WorkerProfile) { + locationOptions += profile.location!!.name + } + val selectedOption = remember { mutableStateOf(selectedLocationIndex) } val maxListHeight = heightRatio * 800 * 0.5f @@ -234,7 +241,12 @@ fun QuickFixLocationFilterBottomSheet( onApplyClick(phoneLocation, range) } else { onApplyClick( - userProfile.locations[selectedOption.value!!.minus(1)], range) + if (profile is UserProfile) { + profile.locations[selectedOption.value!!.minus(1)] + } else if (profile is WorkerProfile) { + profile.location!! + } else Location(0.0, 0.0, "shouldn't happpen"), + range) } onDismissRequest() }, diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/AppContentNavGraph.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/AppContentNavGraph.kt index 228bb35d..511cd89f 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/AppContentNavGraph.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/AppContentNavGraph.kt @@ -103,10 +103,12 @@ fun AppContentNavGraph( composable(AppContentRoute.WORKER_MODE) { WorkerModeNavGraph( modeViewModel, + workerViewModel, isOffline, appContentNavigationActions, preferencesViewModel, accountViewModel, + categoryViewModel, rootNavigationActions, userPreferencesViewModel) } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt index 0e8b20fa..ff8a26cd 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/UserModeNavGraph.kt @@ -104,7 +104,7 @@ fun UserModeNavHost( val announcementViewModel: AnnouncementViewModel = viewModel( factory = - AnnouncementViewModel.Factory( + AnnouncementViewModel.userFactory( announcementRepository = announcementRepository, preferencesRepository = preferencesRepository, userProfileRepository = userProfileRepository)) @@ -211,7 +211,9 @@ fun UserModeNavHost( rootMainNavigationActions, userPreferencesViewModel, appContentNavigationActions, - modeViewModel) + modeViewModel, + userViewModel, + quickFixViewModel) } } @@ -295,7 +297,9 @@ fun ProfileNavHost( rootMainNavigationActions: NavigationActions, userPreferencesViewModel: PreferencesViewModelUserProfile, appContentNavigationActions: NavigationActions, - modeViewModel: ModeViewModel + modeViewModel: ModeViewModel, + userViewModel: ProfileViewModel, + quickFixViewModel: QuickFixViewModel, ) { val profileNavController = rememberNavController() @@ -410,7 +414,8 @@ fun SearchNavHost( announcementViewModel, categoryViewModel, quickFixViewModel, - preferencesViewModel) + preferencesViewModel, + workerViewModel = workerViewModel) } composable(UserScreen.DISPLAY_UPLOADED_IMAGES) { QuickFixDisplayImages(navigationActions, preferencesViewModel, announcementViewModel) @@ -423,7 +428,7 @@ fun SearchNavHost( userViewModel, quickFixViewModel, preferencesViewModel, - ) + workerViewModel = workerViewModel) } composable(UserScreen.SEARCH_LOCATION) { LocationSearchCustomScreen( diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/home/HomeScreen.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/home/HomeScreen.kt index ae844a05..844b137f 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/home/HomeScreen.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/home/HomeScreen.kt @@ -29,10 +29,12 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -55,6 +57,7 @@ import com.arygm.quickfix.model.offline.small.PreferencesViewModel import com.arygm.quickfix.model.profile.ProfileViewModel import com.arygm.quickfix.model.quickfix.QuickFix import com.arygm.quickfix.model.quickfix.QuickFixViewModel +import com.arygm.quickfix.model.tools.ai.GeminiViewModel import com.arygm.quickfix.ressources.C import com.arygm.quickfix.ui.elements.PopularServicesRow import com.arygm.quickfix.ui.elements.QuickFixTextFieldCustom @@ -63,6 +66,7 @@ import com.arygm.quickfix.ui.elements.Service import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScreen +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.tools.ai.QuickFixAIChatScreen import com.arygm.quickfix.utils.loadAppMode import com.arygm.quickfix.utils.loadUserId @@ -76,6 +80,8 @@ fun HomeScreen( quickFixViewModel: QuickFixViewModel ) { val focusManager = LocalFocusManager.current + val geminiViewModel = GeminiViewModel() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) // Sample data for services and quick fixes val services = listOf( @@ -86,6 +92,7 @@ fun HomeScreen( var quickFixes by remember { mutableStateOf(emptyList()) } var mode by remember { mutableStateOf("") } var uid by remember { mutableStateOf("") } + var isChatVisible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { mode = loadAppMode(preferencesViewModel) uid = loadUserId(preferencesViewModel) @@ -102,6 +109,19 @@ fun HomeScreen( BoxWithConstraints { val screenHeight = maxHeight.value val screenWidth = maxWidth.value + + // Modal Bottom Sheet + if (isChatVisible) { + ModalBottomSheet( + onDismissRequest = { + isChatVisible = false + geminiViewModel.clearMessages() // Clear messages when the chat is closed + }, + sheetState = sheetState) { + QuickFixAIChatScreen(viewModel = geminiViewModel) + } + } + Scaffold( modifier = Modifier.pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) } @@ -110,7 +130,11 @@ fun HomeScreen( floatingActionButton = { QuickFixToolboxFloatingButton( iconList = listOf(Icons.Default.Map, Icons.Default.AutoAwesome, Icons.Default.Create), - onIconClick = {}, + onIconClick = { index -> + if (index == 1) { // Assuming you want to handle the second icon + isChatVisible = true + } + }, modifier = Modifier.padding(bottom = (screenHeight * 0.07).dp) .testTag("ToolboxFloatingButton")) diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/navigation/UserNavigation.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/navigation/UserNavigation.kt index d4250c34..a91fcec5 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/navigation/UserNavigation.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/navigation/UserNavigation.kt @@ -29,6 +29,7 @@ object UserScreen { const val ANNOUNCEMENT_DETAIL = "Announcement detail screen" const val QUICKFIX_ONBOARDING = "QuickFix OnBoarding Screen" const val QUICKFIX_DISPLAY_IMAGES = "QuickFix Display Images Screen" + const val SAVED_LIST = "Saved List Screen" } object UserTopLevelDestinations { diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/AccountConfiguration.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/AccountConfiguration.kt index 0572192f..8ce96eba 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/AccountConfiguration.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/AccountConfiguration.kt @@ -1,10 +1,14 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.profile import android.annotation.SuppressLint +import android.graphics.Bitmap import android.widget.Toast -import androidx.compose.foundation.border +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,25 +18,22 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.CameraAlt import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -42,19 +43,22 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import com.arygm.quickfix.R +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import coil.compose.SubcomposeAsyncImage import com.arygm.quickfix.model.account.Account import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.offline.small.PreferencesViewModel import com.arygm.quickfix.ui.elements.QuickFixTextFieldCustom import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography -import com.arygm.quickfix.ui.uiMode.noModeUI.authentication.CustomTextField +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.camera.QuickFixUploadImageSheet import com.arygm.quickfix.utils.isValidDate import com.arygm.quickfix.utils.isValidEmail import com.arygm.quickfix.utils.loadBirthDate @@ -62,31 +66,63 @@ import com.arygm.quickfix.utils.loadEmail import com.arygm.quickfix.utils.loadFirstName import com.arygm.quickfix.utils.loadIsWorker import com.arygm.quickfix.utils.loadLastName +import com.arygm.quickfix.utils.loadProfilePicture +import com.arygm.quickfix.utils.loadUserId import com.arygm.quickfix.utils.setAccountPreferences +import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.firebase.Timestamp import java.util.GregorianCalendar @SuppressLint("StateFlowValueCalledInComposition") -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable fun AccountConfigurationScreen( navigationActions: NavigationActions, accountViewModel: AccountViewModel, preferencesViewModel: PreferencesViewModel ) { - var isWorker = false - val uid by remember { mutableStateOf("Loading...") } - var firstName by remember { mutableStateOf("Loading...") } - var lastName by remember { mutableStateOf("Loading...") } - var email by remember { mutableStateOf("Loading...") } - var birthDate by remember { mutableStateOf("Loading...") } + var uid by remember { mutableStateOf("Loading...") } + var isWorker by remember { mutableStateOf(false) } + // State to store saved data + var savedFirstName by remember { mutableStateOf("Loading...") } + var savedLastName by remember { mutableStateOf("Loading...") } + var savedEmail by remember { mutableStateOf("Loading...") } + var savedBirthDate by remember { mutableStateOf("Loading...") } + var savedProfilePicture by remember { + mutableStateOf("https://example.com/default-profile-pic.jpg") + } + var imageChanged by remember { mutableStateOf(false) } + + // State for input fields + var inputFirstName by remember { mutableStateOf("Loading...") } + var inputLastName by remember { mutableStateOf("Loading...") } + var inputEmail by remember { mutableStateOf("Loading...") } + var inputBirthDate by remember { mutableStateOf("Loading...") } + var inputProfilePicture by remember { mutableStateOf(savedProfilePicture) } + + // State for selected profile image + var profileBitmap by remember { mutableStateOf(null) } + var showBottomSheetPP by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + + var isUploading by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - firstName = loadFirstName(preferencesViewModel) - lastName = loadLastName(preferencesViewModel) - email = loadEmail(preferencesViewModel) - birthDate = loadBirthDate(preferencesViewModel) + // Load saved data + uid = loadUserId(preferencesViewModel) + savedFirstName = loadFirstName(preferencesViewModel) + savedLastName = loadLastName(preferencesViewModel) + savedEmail = loadEmail(preferencesViewModel) + savedBirthDate = loadBirthDate(preferencesViewModel) + savedProfilePicture = loadProfilePicture(preferencesViewModel) isWorker = loadIsWorker(preferencesViewModel) + + // Initialize input fields with saved data + inputFirstName = savedFirstName + inputLastName = savedLastName + inputEmail = savedEmail + inputBirthDate = savedBirthDate + inputProfilePicture = savedProfilePicture } var emailError by remember { mutableStateOf(false) } @@ -94,238 +130,292 @@ fun AccountConfigurationScreen( val context = LocalContext.current - Scaffold( - containerColor = colorScheme.background, - topBar = { - TopAppBar( - title = { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - "Account configuration", - modifier = Modifier.testTag("AccountConfigurationTitle").padding(end = 29.dp), - style = poppinsTypography.headlineMedium, - color = colorScheme.primary) - } - }, - navigationIcon = { - IconButton( - onClick = { navigationActions.goBack() }, - modifier = Modifier.testTag("goBackButton")) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Back", - tint = colorScheme.primary) - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.background), - modifier = Modifier.testTag("AccountConfigurationTopAppBar")) - }, - content = { padding -> - Column( - modifier = Modifier.fillMaxSize().fillMaxWidth().padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally) { - // Account Image Placeholder - Icon( - imageVector = Icons.Default.AccountCircle, - contentDescription = "Account Circle Icon", - tint = colorScheme.surface, - modifier = - Modifier.size(100.dp) - .clip(CircleShape) - .border(2.dp, colorScheme.background, CircleShape) - .testTag("AccountImage")) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenWidth = maxWidth + val screenHeight = maxHeight + + Column( + modifier = + Modifier.fillMaxSize() + .padding(horizontal = screenWidth * 0.05f) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top) { + // Top bar with back button and profile information + Box(modifier = Modifier.fillMaxWidth().padding(vertical = screenHeight * 0.02f)) { + // Back button at the top left + IconButton( + onClick = { navigationActions.goBack() }, + modifier = Modifier.align(Alignment.TopStart).testTag("goBackButton")) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onBackground) + } - Spacer(modifier = Modifier.height(16.dp)) + // Centered profile column with image, name, and email + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.align(Alignment.TopCenter).testTag("CenteredProfileColumn")) { + // Profile image + Box( + modifier = + Modifier.size(screenWidth * 0.28f) + .clip(CircleShape) + .background(Color.Gray) + .testTag("ProfileImage") + .clickable { showBottomSheetPP = true }, + contentAlignment = Alignment.Center) { + profileBitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "Profile Image", + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = ContentScale.Crop // Fit image inside the circle + ) + } + ?: SubcomposeAsyncImage( + model = savedProfilePicture, + contentDescription = "Profile Image", + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = ContentScale.Crop, // Fit image inside the circle + error = { + Icon( + Icons.Outlined.CameraAlt, + contentDescription = "Placeholder", + tint = Color.Gray) + }, + ) + } - // Account Card - Card( - modifier = - Modifier.fillMaxWidth(0.85f) - .align(Alignment.CenterHorizontally) - .testTag("AccountCard"), - shape = RoundedCornerShape(16.dp), - colors = - CardDefaults.cardColors( - containerColor = colorScheme.surface, - contentColor = colorScheme.onSurface), - elevation = CardDefaults.cardElevation(4.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(7.dp)) { - Icon( - painter = painterResource(R.drawable.profilevector), - contentDescription = "Account Icon", - tint = colorScheme.primary, - modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(65.dp)) + Spacer(modifier = Modifier.height(screenHeight * 0.01f)) - val displayName = capitalizeName(firstName, lastName) + // Full name + Text( + text = capitalizeName(savedFirstName, savedLastName), + style = + poppinsTypography.headlineSmall.copy( + fontWeight = FontWeight.Bold, fontSize = 15.sp), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.testTag("ProfileDisplayName"), + textAlign = TextAlign.Center) - Text( - text = displayName, - style = MaterialTheme.typography.bodyLarge, - color = colorScheme.onBackground, - modifier = Modifier.testTag("AccountName")) - } - } + Spacer(modifier = Modifier.height(screenHeight * 0.005f)) - Spacer(modifier = Modifier.height(60.dp)) + // Email + Text( + text = savedEmail, + style = + poppinsTypography.bodySmall.copy( + fontSize = 14.sp, fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + modifier = Modifier.testTag("ProfileEmail")) + } + } - Column( - modifier = - Modifier.align(Alignment.CenterHorizontally) - .padding(16.dp) - .zIndex(100f), // Ensure it's on top - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { - Row( - modifier = Modifier.fillMaxWidth().height(55.dp).padding(start = 8.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically) { - CustomTextField( - value = firstName, - onValueChange = { firstName = it }, - placeHolderText = "First Name", - placeHolderColor = colorScheme.onSecondaryContainer, - label = "First Name", - columnModifier = Modifier.weight(1f), - modifier = Modifier.testTag("firstNameInput")) + Spacer(modifier = Modifier.height(screenHeight * 0.03f)) - CustomTextField( - value = lastName, - onValueChange = { lastName = it }, - placeHolderText = "Last Name", - placeHolderColor = colorScheme.onSecondaryContainer, - label = "Last Name", - columnModifier = Modifier.weight(1f), - modifier = Modifier.testTag("lastNameInput")) - } + // Input fields for First Name and Last Name + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(screenWidth * 0.02f), + verticalAlignment = Alignment.CenterVertically) { + QuickFixTextFieldCustom( + value = inputFirstName, + onValueChange = { inputFirstName = it }, + placeHolderText = "First Name", + placeHolderColor = MaterialTheme.colorScheme.onSecondaryContainer, + showLabel = true, + label = { Text("First Name") }, + shape = RoundedCornerShape(screenWidth * 0.02f), + hasShadow = false, + borderColor = MaterialTheme.colorScheme.tertiaryContainer, + widthField = screenWidth * 0.44f, + heightField = screenHeight * 0.035f, + modifier = Modifier.testTag("firstNameInput")) - Spacer(modifier = Modifier.padding(6.dp)) + QuickFixTextFieldCustom( + value = inputLastName, + onValueChange = { inputLastName = it }, + placeHolderText = "Last Name", + placeHolderColor = MaterialTheme.colorScheme.onSecondaryContainer, + showLabel = true, + label = { Text("Last Name") }, + shape = RoundedCornerShape(screenWidth * 0.02f), + hasShadow = false, + borderColor = MaterialTheme.colorScheme.tertiaryContainer, + widthField = screenWidth * 0.44f, + heightField = screenHeight * 0.035f, + modifier = Modifier.testTag("lastNameInput")) + } - Column(modifier = Modifier.fillMaxWidth().padding(start = 8.dp)) { - QuickFixTextFieldCustom( - value = email, - onValueChange = { - email = it - emailError = !isValidEmail(it) - accountViewModel.accountExists(email) { exists, account -> - emailError = exists && account != null && email != account.email - } - }, - placeHolderText = "Enter your email address", - placeHolderColor = colorScheme.onSecondaryContainer, - isError = emailError, - showError = emailError, - errorText = "INVALID EMAIL", - modifier = Modifier.testTag("emailInput"), - showLabel = true, - label = { - Text( - "Email", - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onBackground, - modifier = Modifier.padding(start = 3.dp).testTag("emailLabel")) - }) - } + Spacer(modifier = Modifier.height(screenHeight * 0.02f)) - Spacer(modifier = Modifier.padding(6.dp)) + // Email Input + QuickFixTextFieldCustom( + value = inputEmail, + onValueChange = { + inputEmail = it + emailError = !isValidEmail(it) + }, + placeHolderText = "E-mail address", + placeHolderColor = MaterialTheme.colorScheme.onSecondaryContainer, + isError = emailError, + errorText = "Invalid Email", + showLabel = true, + label = { Text("E-mail address") }, + shape = RoundedCornerShape(screenWidth * 0.02f), + hasShadow = false, + borderColor = MaterialTheme.colorScheme.tertiaryContainer, + widthField = screenWidth * 0.9f, + heightField = screenHeight * 0.035f, + modifier = Modifier.testTag("emailInput")) - Column(modifier = Modifier.fillMaxWidth().padding(start = 8.dp)) { - QuickFixTextFieldCustom( - modifier = Modifier.testTag("birthDateInput"), - value = birthDate, - onValueChange = { - birthDate = it - birthDateError = !isValidDate(it) - }, - placeHolderText = "Enter your birthdate (DD/MM/YYYY)", - placeHolderColor = colorScheme.onSecondaryContainer, - isError = birthDateError, - errorText = "INVALID DATE", - showError = birthDateError, - showLabel = true, - label = { - Text( - "Birthdate", - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onBackground, - modifier = Modifier.padding(start = 3.dp).testTag("birthDateLabel")) - }) - } - } + Spacer(modifier = Modifier.height(screenHeight * 0.02f)) - Spacer(modifier = Modifier.height(16.dp)) + // Birthdate Input + QuickFixTextFieldCustom( + value = inputBirthDate, + onValueChange = { + inputBirthDate = it + birthDateError = !isValidDate(it) + }, + placeHolderText = "Enter your birthdate (DD/MM/YYYY)", + placeHolderColor = MaterialTheme.colorScheme.onSecondaryContainer, + isError = birthDateError, + errorText = "Invalid Date", + showLabel = true, + label = { Text("Birthdate") }, + shape = RoundedCornerShape(screenWidth * 0.02f), + hasShadow = false, + borderColor = MaterialTheme.colorScheme.tertiaryContainer, + widthField = screenWidth * 0.9f, + heightField = screenHeight * 0.035f, + modifier = Modifier.testTag("birthDateInput")) - // Change password button - Button( - onClick = { /* Handle change password */}, - modifier = - Modifier.fillMaxWidth(0.8f) - .padding(horizontal = 16.dp) - .testTag("ChangePasswordButton"), - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.primary)) { - Icon(Icons.Outlined.Lock, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Change password", - color = colorScheme.onPrimary, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.testTag("ChangePasswordText")) - } + Spacer(modifier = Modifier.height(screenHeight * 0.25f)) - Spacer(modifier = Modifier.height(8.dp)) + // Save Button + val isModified = + inputFirstName != savedFirstName || + inputLastName != savedLastName || + inputEmail != savedEmail || + inputBirthDate != savedBirthDate || + imageChanged - // Save button - Button( - onClick = { - val calendar = GregorianCalendar() - val parts = birthDate.split("/") - if (parts.size == 3) { - try { - calendar.set( - parts[2].toInt(), - parts[1].toInt() - 1, // Months are 0-based indexed - parts[0].toInt(), - 0, - 0, - 0) - val newAccount = + Button( + onClick = { + if (profileBitmap != null) { + // Handle image upload logic + isUploading = true + accountViewModel.uploadAccountImages( + accountId = uid, + images = listOf(profileBitmap!!), + onSuccess = { imageUrls -> + val newProfilePicture = imageUrls.first() + val updatedAccount = Account( uid = uid, - firstName = firstName, - lastName = lastName, - email = email, - birthDate = Timestamp(calendar.time), - isWorker = isWorker) + firstName = inputFirstName, + lastName = inputLastName, + email = inputEmail, + birthDate = + Timestamp( + GregorianCalendar( + inputBirthDate.split("/")[2].toInt(), + inputBirthDate.split("/")[1].toInt() - 1, + inputBirthDate.split("/")[0].toInt()) + .time), + isWorker = isWorker, + profilePicture = newProfilePicture) + accountViewModel.updateAccount( - newAccount, - onSuccess = { setAccountPreferences(preferencesViewModel, newAccount) }, - onFailure = {}) - navigationActions.goBack() - return@Button - } catch (_: NumberFormatException) {} - } + updatedAccount, + onSuccess = { + setAccountPreferences(preferencesViewModel, updatedAccount) + isUploading = false + Toast.makeText(context, "Profile updated!", Toast.LENGTH_SHORT).show() + savedFirstName = inputFirstName + savedLastName = inputLastName + savedEmail = inputEmail + savedBirthDate = inputBirthDate + savedProfilePicture = newProfilePicture + imageChanged = false + }, + onFailure = { + isUploading = false + Toast.makeText(context, "Update failed!", Toast.LENGTH_SHORT).show() + }) + }, + onFailure = { + isUploading = false + Toast.makeText(context, "Image upload failed!", Toast.LENGTH_SHORT).show() + }) + } else { + // Update without changing the image + val updatedAccount = + Account( + uid = uid, + firstName = inputFirstName, + lastName = inputLastName, + email = inputEmail, + birthDate = + Timestamp( + GregorianCalendar( + inputBirthDate.split("/")[2].toInt(), + inputBirthDate.split("/")[1].toInt() - 1, + inputBirthDate.split("/")[0].toInt()) + .time), + isWorker = isWorker, + profilePicture = savedProfilePicture) + accountViewModel.updateAccount( + updatedAccount, + onSuccess = { + setAccountPreferences(preferencesViewModel, updatedAccount) + Toast.makeText(context, "Profile updated!", Toast.LENGTH_SHORT).show() + savedFirstName = inputFirstName + savedLastName = inputLastName + savedEmail = inputEmail + savedBirthDate = inputBirthDate + imageChanged = false + }, + onFailure = { + Toast.makeText(context, "Update failed!", Toast.LENGTH_SHORT).show() + }) + } + navigationActions.goBack() + }, + enabled = isModified && !emailError && !birthDateError, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (isModified) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)), + shape = RoundedCornerShape(50), + modifier = + Modifier.width(screenWidth * 0.85f) + .height(screenHeight * 0.05f) + .testTag("SaveButton")) { + Text("Save") + } + } + } - Toast.makeText( - context, "Invalid format, date must be DD/MM/YYYY.", Toast.LENGTH_SHORT) - .show() - }, - enabled = !emailError && !birthDateError, - modifier = - Modifier.fillMaxWidth(0.8f).padding(horizontal = 16.dp).testTag("SaveButton"), - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.primary)) { - Text( - text = "Save", - color = colorScheme.onPrimary, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.testTag("SaveButtonText")) - } - } + // Image selection interface + QuickFixUploadImageSheet( + sheetState = sheetState, + showModalBottomSheet = showBottomSheetPP, + onDismissRequest = { showBottomSheetPP = false }, + onShowBottomSheetChange = { showBottomSheetPP = it }, + onActionRequest = { bitmap -> + inputProfilePicture = "example.com" + imageChanged = true + profileBitmap = bitmap + showBottomSheetPP = false }) } +// Helper function to capitalize names private fun capitalizeName(firstName: String?, lastName: String?): String { val capitalizedFirstName = firstName?.lowercase()?.replaceFirstChar { it.uppercase() } ?: "" val capitalizedLastName = lastName?.lowercase()?.replaceFirstChar { it.uppercase() } ?: "" diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/UserProfileScreen.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/UserProfileScreen.kt index 4975fab8..155828e1 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/UserProfileScreen.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/UserProfileScreen.kt @@ -50,7 +50,7 @@ fun UserProfileScreen( icon = Icons.Outlined.FavoriteBorder, label = "Saved Lists", testTag = "SavedLists", - action = { /* Action */})) + action = {})) // Define Resources Section val resources = diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/becomeWorker/UpgradeToWorkerScreen.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/becomeWorker/UpgradeToWorkerScreen.kt index e99a16fa..040e7ad2 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/becomeWorker/UpgradeToWorkerScreen.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/profile/becomeWorker/UpgradeToWorkerScreen.kt @@ -56,6 +56,7 @@ import com.arygm.quickfix.utils.loadEmail import com.arygm.quickfix.utils.loadFirstName import com.arygm.quickfix.utils.loadIsWorker import com.arygm.quickfix.utils.loadLastName +import com.arygm.quickfix.utils.loadProfilePicture import com.arygm.quickfix.utils.loadUserId import com.arygm.quickfix.utils.setAccountPreferences import com.arygm.quickfix.utils.stringToTimestamp @@ -95,6 +96,9 @@ fun BusinessScreen( var email by remember { mutableStateOf("") } var birthDate by remember { mutableStateOf("") } var isWorker by remember { mutableStateOf(false) } + var savedProfilePicture by remember { + mutableStateOf("https://example.com/default-profile-pic.jpg") + } LaunchedEffect(Unit) { workerId = loadUserId(preferencesViewModel) @@ -103,6 +107,7 @@ fun BusinessScreen( email = loadEmail(preferencesViewModel) birthDate = loadBirthDate(preferencesViewModel) isWorker = loadIsWorker(preferencesViewModel) + savedProfilePicture = loadProfilePicture(preferencesViewModel) } val handleSuccessfulImageUpload: (String, List) -> Unit = @@ -134,7 +139,8 @@ fun BusinessScreen( lastName = lastName, email = email, birthDate = stringToTimestamp(birthDate) ?: Timestamp.now(), - isWorker = true) + isWorker = true, + profilePicture = savedProfilePicture) accountViewModel.updateAccount( newAccount, onSuccess = { setAccountPreferences(preferencesViewModel, newAccount) }, diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt index aa484e82..74300b61 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ExpandableCategoryItem.kt @@ -132,7 +132,6 @@ fun ExpandableCategoryItem( testTag = "${C.Tag.subCategoryName}_${it.name}" } .clickable { - searchViewModel.updateSearchQuery(it.name) searchViewModel.setSearchSubcategory(it) searchViewModel.filterWorkersBySubcategory(it.name) { navigationActions.navigateTo( diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt index 4b47f9e1..b4f941b7 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt @@ -1,5 +1,7 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search +import android.graphics.Bitmap +import android.util.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn @@ -14,9 +16,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import com.arygm.quickfix.MainActivity -import com.arygm.quickfix.R import com.arygm.quickfix.model.account.Account import com.arygm.quickfix.model.account.AccountViewModel +import com.arygm.quickfix.model.profile.ProfileViewModel import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.utils.GeocoderWrapper @@ -30,6 +32,7 @@ fun ProfileResults( listState: LazyListState, searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, + workerViewModel: ProfileViewModel, geocoderWrapper: GeocoderWrapper = GeocoderWrapper(LocalContext.current), onBookClick: (WorkerProfile, String) -> Unit ) { @@ -40,64 +43,69 @@ fun ProfileResults( ?: addresses?.firstOrNull()?.adminArea } - LazyColumn( - modifier = modifier.fillMaxWidth().testTag("worker_profiles_list"), state = listState) { - items(profiles.size) { index -> - val profile = profiles[index] - var account by remember { mutableStateOf(null) } - var distance by remember { mutableStateOf(null) } + val context = LocalContext.current + val locationHelper = remember { LocationHelper(context, MainActivity()) } - // Get user's current location and calculate distance - val locationHelper = LocationHelper(LocalContext.current, MainActivity()) - locationHelper.getCurrentLocation { location -> - location?.let { - distance = - profile.location?.let { workerLocation -> - searchViewModel - .calculateDistance( - workerLocation.latitude, - workerLocation.longitude, - it.latitude, - it.longitude) - .toInt() - } - } - } + LazyColumn(modifier = modifier.fillMaxWidth().testTag("worker_profiles_list"), state = listState) { + items(profiles.size) { index -> + val profile = profiles[index] - // Fetch user account details - LaunchedEffect(profile.uid) { - accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> - account = fetchedAccount - } - } + var account by remember { mutableStateOf(null) } + var distance by remember { mutableStateOf(null) } + var profileImage by remember { mutableStateOf(null) } + var cityName by remember { mutableStateOf(null) } + + // Fetch data once using LaunchedEffect, keyed by profile.uid + LaunchedEffect(profile.uid) { + // Fetch profile image once + workerViewModel.fetchProfileImageAsBitmap( + profile.uid, + onSuccess = { profileImage = it }, + onFailure = { Log.e("ProfileResults", "Failed to fetch profile image: $it") }) + + // Fetch account data once + accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> + account = fetchedAccount + } - // Render profile card if account data is available - account?.let { acc -> - var cityName by remember { mutableStateOf(null) } - profile.location.let { - cityName = - profile.location?.let { it1 -> - getCityNameFromCoordinates(it1.latitude, profile.location.longitude) - } - val displayLoc = if (cityName != null) cityName else "Unknown" - if (displayLoc != null) { - SearchWorkerProfileResult( - modifier = - Modifier.fillMaxWidth() - .testTag("worker_profile_result_$index") - .clickable {}, - profileImage = R.drawable.placeholder_worker, - name = "${acc.firstName} ${acc.lastName}", - category = profile.fieldOfWork, - rating = profile.rating, - reviewCount = profile.reviews.size, - location = displayLoc, - price = profile.price.roundToInt().toString(), - distance = distance, - onBookClick = { onBookClick(profile, displayLoc) }) - } - } + // Get current location once + locationHelper.getCurrentLocation { location -> + location?.let { + distance = + profile.location?.let { workerLocation -> + searchViewModel + .calculateDistance( + workerLocation.latitude, + workerLocation.longitude, + it.latitude, + it.longitude) + .toInt() + } } + + // Compute city name once + cityName = + profile.location?.let { loc -> + getCityNameFromCoordinates(loc.latitude, loc.longitude) + } ?: "Unknown" } } + + // Only show the result once all required data is available + val displayLoc = cityName ?: "Unknown" + if (account != null && profileImage != null) { + SearchWorkerProfileResult( + modifier = Modifier.fillMaxWidth().testTag("worker_profile_result_$index").clickable {}, + profileImage = profileImage!!, + name = "${account!!.firstName} ${account!!.lastName}", + category = profile.fieldOfWork, + rating = profile.rating, + reviewCount = profile.reviews.size, + location = displayLoc, + price = profile.price.roundToInt().toString(), + distance = distance, + onBookClick = { onBookClick(profile, displayLoc) }) + } + } + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt index 9062668a..3898bea7 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager @@ -33,6 +34,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel @@ -45,6 +47,8 @@ import com.arygm.quickfix.model.search.AnnouncementViewModel import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScreen +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.AnnouncementScreen +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchOnBoarding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -61,7 +65,8 @@ fun QuickFixFinderScreen( categoryViewModel: CategoryViewModel = viewModel(factory = CategoryViewModel.Factory(LocalContext.current)), quickFixViewModel: QuickFixViewModel, - preferencesViewModel: PreferencesViewModel + preferencesViewModel: PreferencesViewModel, + workerViewModel: ProfileViewModel ) { var isWindowVisible by remember { mutableStateOf(false) } var pager by remember { mutableStateOf(true) } @@ -79,59 +84,49 @@ fun QuickFixFinderScreen( val screenHeight = maxHeight val screenWidth = maxWidth - Scaffold( - containerColor = colorBackground, - topBar = { - TopAppBar( - title = { - Text( - text = "Quickfix", - color = colorScheme.primary, - style = typography.headlineLarge, - modifier = Modifier.testTag("QuickFixFinderTopBarTitle")) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = colorBackground), - modifier = Modifier.testTag("QuickFixFinderTopBar")) - }, - content = { padding -> - Column( - modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally) { - val coroutineScope = rememberCoroutineScope() - - if (pager) { - Surface( - color = colorButton, - shape = RoundedCornerShape(screenWidth * 0.05f), - modifier = - Modifier.padding(horizontal = screenWidth * 0.1f) - .clip(RoundedCornerShape(screenWidth * 0.05f))) { - TabRow( - selectedTabIndex = pagerState.currentPage, - containerColor = Color.Transparent, - divider = {}, - indicator = {}, - modifier = - Modifier.padding( - horizontal = screenWidth * 0.0025f, - vertical = screenWidth * 0.0025f) - .align(Alignment.CenterHorizontally) - .testTag("quickFixSearchTabRow")) { - QuickFixScreenTab( - pagerState, coroutineScope, 0, "Search", screenWidth) - QuickFixScreenTab( - pagerState, coroutineScope, 1, "Announce", screenWidth) - } - } - } - - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - modifier = Modifier.testTag("quickFixSearchPager")) { page -> - when (page) { - 0 -> { + Scaffold( + containerColor = colorBackground, + topBar = { + TopAppBar( + title = { + val coroutineScope = rememberCoroutineScope() + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize().padding(end = 20.dp)) { + Surface( + color = colorButton, + shape = RoundedCornerShape(20.dp), + modifier = + Modifier.padding(horizontal = 40.dp).clip(RoundedCornerShape(20.dp))) { + TabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = Color.Transparent, + divider = {}, + indicator = {}, + modifier = + Modifier.padding(horizontal = 1.dp, vertical = 1.dp) + .testTag("quickFixSearchTabRow")) { + QuickFixScreenTab(pagerState, coroutineScope, 0, "Search", screenWidth) + QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce", screenWidth) + } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = colorBackground), + modifier = Modifier.testTag("QuickFixFinderTopBar")) + }, + content = { padding -> + Column( + modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.testTag("quickFixSearchPager")) { page -> + when (page) { + 0 -> { SearchOnBoarding( navigationActions, navigationActionsRoot, @@ -145,7 +140,7 @@ fun QuickFixFinderScreen( initialSaved = false workerAddress = locName isWindowVisible = true - }) + }, workerViewModel = workerViewModel) } 1 -> { AnnouncementScreen( diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index 7783a366..92f15006 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -1,25 +1,32 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search +import android.graphics.Bitmap +import android.util.Log import android.widget.Toast import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -38,6 +45,7 @@ import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.UserProfile +import com.arygm.quickfix.model.profile.ProfileViewModel import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.elements.ChooseServiceTypeSheet @@ -58,6 +66,7 @@ fun SearchOnBoarding( accountViewModel: AccountViewModel, categoryViewModel: CategoryViewModel, onProfileClick: (WorkerProfile, String) -> Unit, + workerViewModel: ProfileViewModel ) { val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } val workerProfiles by searchViewModel.workerProfilesSuggestions.collectAsState() @@ -103,6 +112,40 @@ fun SearchOnBoarding( setUiState(uiState.copy(showPriceRangeBottomSheet = true)) }, onShowLocationBottomSheet = { setUiState(uiState.copy(showLocationBottomSheet = true)) }) + + val profileImagesMap by remember { mutableStateOf(mutableMapOf()) } + val bannerImagesMap by remember { mutableStateOf(mutableMapOf()) } + var loading by remember { mutableStateOf(true) } + // Tracks if data is loading + LaunchedEffect(workerProfiles) { + if (workerProfiles.isNotEmpty()) { + workerProfiles.forEach { profile -> + // Fetch profile images + workerViewModel.fetchProfileImageAsBitmap( + profile.uid, + onSuccess = { bitmap -> + profileImagesMap[profile.uid] = bitmap + checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { + loading = false + } + }, + onFailure = { Log.e("ProfileResults", "Failed to fetch profile image") }) + + // Fetch banner images + workerViewModel.fetchBannerImageAsBitmap( + profile.uid, + onSuccess = { bitmap -> + bannerImagesMap[profile.uid] = bitmap + checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { + loading = false + } + }, + onFailure = { Log.e("ProfileResults", "Failed to fetch banner image") }) + } + } else { + loading = false // No profiles to load + } + } BoxWithConstraints { val widthRatio = maxWidth.value / 411f val heightRatio = maxHeight.value / 860f @@ -113,101 +156,120 @@ fun SearchOnBoarding( Scaffold( containerColor = colorScheme.background, content = { padding -> - Column( - modifier = - Modifier.fillMaxWidth().padding(padding).padding(top = 40.dp * heightRatio), - horizontalAlignment = Alignment.CenterHorizontally) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 0.dp * heightRatio), - horizontalArrangement = Arrangement.Center) { - QuickFixTextFieldCustom( - modifier = Modifier.testTag("searchContent"), - showLeadingIcon = { true }, - leadingIcon = Icons.Outlined.Search, - showTrailingIcon = { searchQuery.isNotEmpty() }, - trailingIcon = { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = "Clear search query", - tint = colorScheme.onBackground, - modifier = Modifier.testTag("clearSearchIcon"), - ) - }, - placeHolderText = "Find your perfect fix with QuickFix", - value = searchQuery, - onValueChange = { - searchQuery = it - searchViewModel.searchEngine(it) - }, - shape = CircleShape, - textStyle = poppinsTypography.bodyMedium, - textColor = colorScheme.onBackground, - placeHolderColor = colorScheme.onBackground, - leadIconColor = colorScheme.onBackground, - widthField = 300.dp * widthRatio, - heightField = 40.dp * heightRatio, - moveContentHorizontal = 10.dp * widthRatio, - moveContentBottom = 0.dp, - moveContentTop = 0.dp, - sizeIconGroup = 30.dp * sizeRatio, - spaceBetweenLeadIconText = 0.dp, - onClick = true, - ) - Spacer(modifier = Modifier.width(10.dp * widthRatio)) - QuickFixButton( - buttonText = "Cancel", - textColor = colorScheme.onBackground, - buttonColor = colorScheme.background, - buttonOpacity = 1f, - textStyle = poppinsTypography.labelSmall, - onClickAction = { - navigationActionsRoot.navigateTo(UserTopLevelDestinations.HOME) - }, - contentPadding = PaddingValues(0.dp), - ) - } - if (searchQuery.isEmpty()) { - // Show Categories - CategoryContent( - navigationActions = navigationActions, - searchViewModel = searchViewModel, - listState = listState, - expandedStates = expandedStates, - itemCategories = itemCategories, - widthRatio = widthRatio, - heightRatio = heightRatio, - ) - } else { - // Show Profiles - // Insert filter buttons here (only when searchQuery is not empty) - Column { + if (loading) { + // Display a loader + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = colorScheme.primary, modifier = Modifier.size(64.dp)) + } + } else { + Column( + modifier = + Modifier.fillMaxWidth().padding(padding).padding(top = 40.dp * heightRatio), + horizontalAlignment = Alignment.CenterHorizontally + ) { Row( - modifier = - Modifier.fillMaxWidth() - .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) - .padding(horizontal = screenWidth * 0.02f), - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 0.dp * heightRatio), + horizontalArrangement = Arrangement.Center ) { - FilterRow( - showFilterButtons = uiState.showFilterButtons, - toggleFilterButtons = { - setUiState(uiState.copy(showFilterButtons = !uiState.showFilterButtons)) - }, - listOfButtons = listOfButtons, - modifier = Modifier.padding(bottom = screenHeight * 0.01f), - screenWidth = screenWidth, - screenHeight = screenHeight) + QuickFixTextFieldCustom( + modifier = Modifier.testTag("searchContent"), + showLeadingIcon = { true }, + leadingIcon = Icons.Outlined.Search, + showTrailingIcon = { searchQuery.isNotEmpty() }, + trailingIcon = { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear search query", + tint = colorScheme.onBackground, + modifier = Modifier.testTag("clearSearchIcon"), + ) + }, + placeHolderText = "Find your perfect fix with QuickFix", + value = searchQuery, + onValueChange = { + searchQuery = it + searchViewModel.searchEngine(it) + }, + shape = CircleShape, + textStyle = poppinsTypography.bodyMedium, + textColor = colorScheme.onBackground, + placeHolderColor = colorScheme.onBackground, + leadIconColor = colorScheme.onBackground, + widthField = 300.dp * widthRatio, + heightField = 40.dp * heightRatio, + moveContentHorizontal = 10.dp * widthRatio, + moveContentBottom = 0.dp, + moveContentTop = 0.dp, + sizeIconGroup = 30.dp * sizeRatio, + spaceBetweenLeadIconText = 0.dp, + onClick = true, + ) + Spacer(modifier = Modifier.width(10.dp * widthRatio)) + QuickFixButton( + buttonText = "Cancel", + textColor = colorScheme.onBackground, + buttonColor = colorScheme.background, + buttonOpacity = 1f, + textStyle = poppinsTypography.labelSmall, + onClickAction = { + navigationActionsRoot.navigateTo(UserTopLevelDestinations.HOME) + }, + contentPadding = PaddingValues(0.dp), + ) + } + if (searchQuery.isEmpty()) { + // Show Categories + CategoryContent( + navigationActions = navigationActions, + searchViewModel = searchViewModel, + listState = listState, + expandedStates = expandedStates, + itemCategories = itemCategories, + widthRatio = widthRatio, + heightRatio = heightRatio, + ) + } else { + // Show Profiles + // Insert filter buttons here (only when searchQuery is not empty) + Column { + Row( + modifier = + Modifier.fillMaxWidth() + .padding( + top = screenHeight * 0.02f, + bottom = screenHeight * 0.01f + ) + .padding(horizontal = screenWidth * 0.02f), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterRow( + showFilterButtons = uiState.showFilterButtons, + toggleFilterButtons = { + setUiState(uiState.copy(showFilterButtons = !uiState.showFilterButtons)) + }, + listOfButtons = listOfButtons, + modifier = Modifier.padding(bottom = screenHeight * 0.01f), + screenWidth = screenWidth, + screenHeight = screenHeight + ) + } + } } - } + ProfileResults( + profiles = filteredWorkerProfiles, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + listState = listState, + onBookClick = { selectedProfile, loc -> + onProfileClick( + selectedProfile, + loc + ) + }, workerViewModel = workerViewModel + ) } - ProfileResults( - profiles = filteredWorkerProfiles, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - listState = listState, - onBookClick = { selectedProfile, loc -> onProfileClick(selectedProfile, loc) }) - } - }, + }}, modifier = Modifier.pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) @@ -271,7 +333,7 @@ fun SearchOnBoarding( userProfile?.let { QuickFixLocationFilterBottomSheet( uiState.showLocationBottomSheet, - userProfile = it, + profile = it, phoneLocation = filterState.phoneLocation, selectedLocationIndex = selectedLocationIndex, onApplyClick = { location, max -> @@ -306,4 +368,4 @@ fun SearchOnBoarding( end = lastAppliedMaxDist) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerProfileResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerProfileResult.kt index 9159be19..e0a9666a 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerProfileResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerProfileResult.kt @@ -1,6 +1,7 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search import android.annotation.SuppressLint +import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -26,9 +27,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -36,12 +38,13 @@ import androidx.compose.ui.unit.sp import com.arygm.quickfix.ui.elements.QuickFixButton import com.arygm.quickfix.ui.theme.poppinsFontFamily import com.arygm.quickfix.ui.theme.poppinsTypography +import java.util.Locale @SuppressLint("DefaultLocale") @Composable fun SearchWorkerProfileResult( modifier: Modifier = Modifier, - profileImage: Int, + profileImage: Bitmap, name: String, category: String, rating: Double, @@ -78,7 +81,7 @@ fun SearchWorkerProfileResult( modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) { Image( - painter = painterResource(id = profileImage), + painter = BitmapPainter(profileImage.asImageBitmap()), contentDescription = "Profile image of $name, $category", modifier = Modifier.clip(RoundedCornerShape(8.dp)).size(100.dp).aspectRatio(1f), contentScale = ContentScale.FillBounds) @@ -93,7 +96,7 @@ fun SearchWorkerProfileResult( verticalArrangement = Arrangement.Top, ) { Row(verticalAlignment = Alignment.CenterVertically) { - val roundedRating = String.format("%.2f", rating).toDouble() + val roundedRating = String.format(Locale.US, "%.2f", rating).toDouble() Text( text = "$roundedRating ★", fontFamily = poppinsFontFamily, @@ -137,12 +140,6 @@ fun SearchWorkerProfileResult( lineHeight = 20.sp, color = colorScheme.onBackground, modifier = Modifier.testTag("price")) - Text( - text = "/Hour", - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 20.sp, - color = colorScheme.onSurface) } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index 4b77bbe6..ee75c76b 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -1,19 +1,26 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search +import android.graphics.Bitmap +import android.util.Log import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -33,8 +40,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arygm.quickfix.MainActivity import com.arygm.quickfix.R @@ -56,7 +65,7 @@ import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScree import com.arygm.quickfix.utils.LocationHelper import com.arygm.quickfix.utils.loadUserId -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun SearchWorkerResult( navigationActions: NavigationActions, @@ -65,275 +74,356 @@ fun SearchWorkerResult( userProfileViewModel: ProfileViewModel, quickFixViewModel: QuickFixViewModel, preferencesViewModel: PreferencesViewModel, + workerViewModel: ProfileViewModel, ) { - val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } - var isWindowVisible by remember { mutableStateOf(false) } - val context = LocalContext.current - val locationHelper = LocationHelper(context, MainActivity()) - var selectedWorkerProfile by remember { mutableStateOf(WorkerProfile()) } - val filterState = rememberSearchFiltersState() - var baseLocation by remember { mutableStateOf(filterState.phoneLocation) } - var userProfile by remember { mutableStateOf(null) } - var uid by remember { mutableStateOf("Loading...") } - val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() + val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } + var isWindowVisible by remember { mutableStateOf(false) } + val context = LocalContext.current + val locationHelper = LocationHelper(context, MainActivity()) + var selectedWorkerProfile by remember { mutableStateOf(WorkerProfile()) } + val filterState = rememberSearchFiltersState() + var baseLocation by remember { mutableStateOf(filterState.phoneLocation) } + var userProfile by remember { mutableStateOf(null) } + var uid by remember { mutableStateOf("Loading...") } + val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() - // Fetch user and set base location + // Fetch user and set base location - LaunchedEffect(Unit) { - if (locationHelper.checkPermissions()) { - locationHelper.getCurrentLocation { location -> - if (location != null) { - val userLoc = Location(location.latitude, location.longitude, "Phone Location") - filterState.phoneLocation = userLoc + var loading by remember { mutableStateOf(true) } // Tracks if data is loading + + LaunchedEffect(Unit) { + if (locationHelper.checkPermissions()) { + locationHelper.getCurrentLocation { location -> + if (location != null) { + val userLoc = Location(location.latitude, location.longitude, "Phone Location") + filterState.phoneLocation = userLoc + } else { + Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() + } + } } else { - Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } + uid = loadUserId(preferencesViewModel) + userProfileViewModel.fetchUserProfile(uid) { profile -> + userProfile = profile as UserProfile } - } - } else { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() } - uid = loadUserId(preferencesViewModel) - userProfileViewModel.fetchUserProfile(uid) { profile -> userProfile = profile as UserProfile } - } - val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() - var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } - val searchCategory by searchViewModel.searchCategory.collectAsState() + val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() + var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } + val searchCategory by searchViewModel.searchCategory.collectAsState() - var locationFilterApplied by remember { mutableStateOf(false) } - var selectedLocation by remember { mutableStateOf(Location()) } - var maxDistance by remember { mutableIntStateOf(0) } - var selectedLocationIndex by remember { mutableStateOf(null) } - var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerAddress by remember { mutableStateOf("") } + var locationFilterApplied by remember { mutableStateOf(false) } + var selectedLocation by remember { mutableStateOf(Location()) } + var maxDistance by remember { mutableIntStateOf(0) } + var selectedLocationIndex by remember { mutableStateOf(null) } + var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } + var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } + var initialSaved by remember { mutableStateOf(false) } + var workerAddress by remember { mutableStateOf("") } - var lastAppliedMaxDist by remember { mutableIntStateOf(200) } + var lastAppliedMaxDist by remember { mutableIntStateOf(200) } - val listState = rememberLazyListState() - fun updateFilteredProfiles() { - filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) - } + val listState = rememberLazyListState() + fun updateFilteredProfiles() { + filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) + } - val listOfButtons = - filterState.getFilterButtons( - workerProfiles = workerProfiles, - filteredProfiles = filteredWorkerProfiles, - searchViewModel = searchViewModel, - onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, - onShowAvailabilityBottomSheet = { - setUiState(uiState.copy(showAvailabilityBottomSheet = true)) - }, - onShowServicesBottomSheet = { setUiState(uiState.copy(showServicesBottomSheet = true)) }, - onShowPriceRangeBottomSheet = { - setUiState(uiState.copy(showPriceRangeBottomSheet = true)) - }, - onShowLocationBottomSheet = { setUiState(uiState.copy(showLocationBottomSheet = true)) }) - // Wrap everything in a Box to allow overlay - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val screenHeight = maxHeight - val screenWidth = maxWidth + val listOfButtons = + filterState.getFilterButtons( + workerProfiles = workerProfiles, + filteredProfiles = filteredWorkerProfiles, + searchViewModel = searchViewModel, + onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, + onShowAvailabilityBottomSheet = { + setUiState(uiState.copy(showAvailabilityBottomSheet = true)) + }, + onShowServicesBottomSheet = { setUiState(uiState.copy(showServicesBottomSheet = true)) }, + onShowPriceRangeBottomSheet = { + setUiState(uiState.copy(showPriceRangeBottomSheet = true)) + }, + onShowLocationBottomSheet = { setUiState(uiState.copy(showLocationBottomSheet = true)) }) + // Wrap everything in a Box to allow overlay + val profileImagesMap by remember { mutableStateOf(mutableMapOf()) } + val bannerImagesMap by remember { mutableStateOf(mutableMapOf()) } - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { - Text(text = "Search Results", style = MaterialTheme.typography.titleMedium) - }, - navigationIcon = { - IconButton(onClick = { navigationActions.goBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = {}) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = colorScheme.onBackground) + // Check if all required data is fetched + LaunchedEffect(workerProfiles) { + if (workerProfiles.isNotEmpty()) { + workerProfiles.forEach { profile -> + // Fetch profile images + workerViewModel.fetchProfileImageAsBitmap( + profile.uid, + onSuccess = { bitmap -> + profileImagesMap[profile.uid] = bitmap + checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { + loading = false + } + }, + onFailure = { Log.e("ProfileResults", "Failed to fetch profile image") }) + + // Fetch banner images + workerViewModel.fetchBannerImageAsBitmap( + profile.uid, + onSuccess = { bitmap -> + bannerImagesMap[profile.uid] = bitmap + checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { + loading = false + } + }, + onFailure = { Log.e("ProfileResults", "Failed to fetch banner image") }) + } + } else { + loading = false // No profiles to load + } + } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenHeight = maxHeight + val screenWidth = maxWidth + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text(text = "Search Results", style = MaterialTheme.typography.titleMedium) + }, + navigationIcon = { + IconButton(onClick = { navigationActions.goBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + IconButton(onClick = { /* Handle search */ }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = colorScheme.onBackground + ) + } + }, + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = colorScheme.surface + ), + ) + }) { paddingValues -> + // Main content inside the Scaffold + if (loading) { + // Display a loader + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = colorScheme.primary, modifier = Modifier.size(64.dp) + ) } - }, - colors = - TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = colorScheme.background), - ) - }) { paddingValues -> - Column( - modifier = Modifier.fillMaxWidth().padding(paddingValues), - horizontalAlignment = Alignment.CenterHorizontally) { + } else { Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top) { - Text( - text = searchSubcategory?.name ?: "Unknown", - style = poppinsTypography.labelMedium, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - Text( - text = searchCategory?.description ?: "Unknown", - style = poppinsTypography.labelSmall, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - color = colorScheme.onSurface, - textAlign = TextAlign.Center, - ) + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Text( + text = searchSubcategory?.name ?: "Unknown", + style = poppinsTypography.labelMedium, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Text( + text = searchCategory?.description ?: "Unknown", + style = poppinsTypography.labelSmall, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + color = colorScheme.onSurface, + textAlign = TextAlign.Center, + ) } - Row( - modifier = - Modifier.fillMaxWidth() + Row( + modifier = + Modifier + .fillMaxWidth() .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) .padding(horizontal = screenWidth * 0.02f) - .wrapContentHeight(), - verticalAlignment = Alignment.CenterVertically, - ) { - FilterRow( - showFilterButtons = uiState.showFilterButtons, - toggleFilterButtons = { - setUiState(uiState.copy(showFilterButtons = !uiState.showFilterButtons)) - }, - listOfButtons = listOfButtons, - modifier = Modifier.padding(bottom = screenHeight * 0.01f), - screenWidth = screenWidth, - screenHeight = screenHeight) + .wrapContentHeight() + .background(colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterRow( + showFilterButtons = uiState.showFilterButtons, + toggleFilterButtons = { + setUiState(uiState.copy(showFilterButtons = !uiState.showFilterButtons)) + }, + listOfButtons = listOfButtons, + modifier = Modifier.padding(bottom = screenHeight * 0.01f), + screenWidth = screenWidth, + screenHeight = screenHeight + ) + } + ProfileResults( + profiles = filteredWorkerProfiles, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + listState = listState, + onBookClick = { selectedProfile, locName -> + bannerImage = R.drawable.moroccan_flag + profilePicture = R.drawable.placeholder_worker + initialSaved = false + workerAddress = locName + isWindowVisible = true + selectedWorkerProfile = selectedProfile + }, workerViewModel = workerViewModel + ) } - ProfileResults( - profiles = filteredWorkerProfiles, - modifier = Modifier.fillMaxWidth().weight(1f), - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - listState = listState, - onBookClick = { selectedProfile, locName -> - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerAddress = locName - isWindowVisible = true - selectedWorkerProfile = selectedProfile - }, - ) - } + } } - // Bottom sheets for filters - QuickFixAvailabilityBottomSheet( - uiState.showAvailabilityBottomSheet, - onDismissRequest = { setUiState(uiState.copy(showAvailabilityBottomSheet = false)) }, - onOkClick = { days, hour, minute -> - filterState.selectedDays = days - filterState.selectedHour = hour - filterState.selectedMinute = minute - filterState.availabilityFilterApplied = true - updateFilteredProfiles() - }, - onClearClick = { - filterState.availabilityFilterApplied = false - filterState.selectedDays = emptyList() - filterState.selectedHour = 0 - filterState.selectedMinute = 0 - updateFilteredProfiles() - }, - clearEnabled = filterState.availabilityFilterApplied) + QuickFixAvailabilityBottomSheet( + uiState.showAvailabilityBottomSheet, + onDismissRequest = { setUiState(uiState.copy(showAvailabilityBottomSheet = false)) }, + onOkClick = { days, hour, minute -> + filterState.selectedDays = days + filterState.selectedHour = hour + filterState.selectedMinute = minute + filterState.availabilityFilterApplied = true + updateFilteredProfiles() + }, + onClearClick = { + filterState.availabilityFilterApplied = false + filterState.selectedDays = emptyList() + filterState.selectedHour = 0 + filterState.selectedMinute = 0 + updateFilteredProfiles() + }, + clearEnabled = filterState.availabilityFilterApplied + ) - searchSubcategory?.let { - ChooseServiceTypeSheet( - uiState.showServicesBottomSheet, - it.tags, - selectedServices = filterState.selectedServices, - onApplyClick = { services -> - filterState.selectedServices = services - filterState.servicesFilterApplied = true - updateFilteredProfiles() - }, - onDismissRequest = { setUiState(uiState.copy(showServicesBottomSheet = false)) }, - onClearClick = { - filterState.selectedServices = emptyList() - filterState.servicesFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = filterState.servicesFilterApplied) - } + searchSubcategory?.let { + ChooseServiceTypeSheet( + uiState.showServicesBottomSheet, + it.tags, + selectedServices = filterState.selectedServices, + onApplyClick = { services -> + filterState.selectedServices = services + filterState.servicesFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { setUiState(uiState.copy(showServicesBottomSheet = false)) }, + onClearClick = { + filterState.selectedServices = emptyList() + filterState.servicesFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.servicesFilterApplied + ) + } - QuickFixPriceRangeBottomSheet( - uiState.showPriceRangeBottomSheet, - onApplyClick = { start, end -> - filterState.selectedPriceStart = start - filterState.selectedPriceEnd = end - filterState.priceFilterApplied = true - updateFilteredProfiles() - }, - onDismissRequest = { setUiState(uiState.copy(showPriceRangeBottomSheet = false)) }, - onClearClick = { - filterState.selectedPriceStart = 0 - filterState.selectedPriceEnd = 0 - filterState.priceFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = filterState.priceFilterApplied) + QuickFixPriceRangeBottomSheet( + uiState.showPriceRangeBottomSheet, + onApplyClick = { start, end -> + filterState.selectedPriceStart = start + filterState.selectedPriceEnd = end + filterState.priceFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { setUiState(uiState.copy(showPriceRangeBottomSheet = false)) }, + onClearClick = { + filterState.selectedPriceStart = 0 + filterState.selectedPriceEnd = 0 + filterState.priceFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.priceFilterApplied + ) - userProfile?.let { - QuickFixLocationFilterBottomSheet( - uiState.showLocationBottomSheet, - userProfile = it, - phoneLocation = filterState.phoneLocation, - selectedLocationIndex = selectedLocationIndex, - onApplyClick = { location, max -> - selectedLocation = location - lastAppliedMaxDist = max - baseLocation = location - maxDistance = max - selectedLocationIndex = it.locations.indexOf(location) + 1 + userProfile?.let { + QuickFixLocationFilterBottomSheet( + uiState.showLocationBottomSheet, + profile = it, + phoneLocation = filterState.phoneLocation, + selectedLocationIndex = selectedLocationIndex, + onApplyClick = { location, max -> + selectedLocation = location + lastAppliedMaxDist = max + baseLocation = location + maxDistance = max + selectedLocationIndex = it.locations.indexOf(location) + 1 - if (location == Location(0.0, 0.0, "Default")) { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() - } - if (locationFilterApplied) { - updateFilteredProfiles() - } else { - filteredWorkerProfiles = - searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) - } - locationFilterApplied = true - }, - onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, - onClearClick = { - baseLocation = filterState.phoneLocation - lastAppliedMaxDist = 200 - selectedLocation = Location() - maxDistance = 0 - selectedLocationIndex = null - locationFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = locationFilterApplied, - end = lastAppliedMaxDist) + if (location == Location(0.0, 0.0, "Default")) { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT) + .show() + } + if (locationFilterApplied) { + updateFilteredProfiles() + } else { + filteredWorkerProfiles = + searchViewModel.filterWorkersByDistance( + filteredWorkerProfiles, + location, + max + ) + } + locationFilterApplied = true + }, + onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, + onClearClick = { + baseLocation = filterState.phoneLocation + lastAppliedMaxDist = 200 + selectedLocation = Location() + maxDistance = 0 + selectedLocationIndex = null + locationFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = locationFilterApplied, + end = lastAppliedMaxDist + ) + } + QuickFixSlidingWindowWorker( + isVisible = isWindowVisible, + onDismiss = { isWindowVisible = false }, + screenHeight = maxHeight, + screenWidth = maxWidth, + onContinueClick = { + quickFixViewModel.setSelectedWorkerProfile(selectedWorkerProfile) + navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) + }, + bannerImage = bannerImage, + profilePicture = profilePicture, + initialSaved = initialSaved, + workerCategory = selectedWorkerProfile.fieldOfWork, + workerAddress = workerAddress, + description = selectedWorkerProfile.description, + includedServices = selectedWorkerProfile.includedServices.map { it.name }, + addonServices = selectedWorkerProfile.addOnServices.map { it.name }, + workerRating = selectedWorkerProfile.reviews.map { it1 -> it1.rating }.average(), + tags = selectedWorkerProfile.tags, + reviews = selectedWorkerProfile.reviews.map { it.review }, + ) } - QuickFixSlidingWindowWorker( - isVisible = isWindowVisible, - onDismiss = { isWindowVisible = false }, - screenHeight = maxHeight, - screenWidth = maxWidth, - onContinueClick = { - quickFixViewModel.setSelectedWorkerProfile(selectedWorkerProfile) - navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) - }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = initialSaved, - workerCategory = selectedWorkerProfile.fieldOfWork, - workerAddress = workerAddress, - description = selectedWorkerProfile.description, - includedServices = selectedWorkerProfile.includedServices.map { it.name }, - addonServices = selectedWorkerProfile.addOnServices.map { it.name }, - workerRating = selectedWorkerProfile.reviews.map { it1 -> it1.rating }.average(), - tags = selectedWorkerProfile.tags, - reviews = selectedWorkerProfile.reviews.map { it.review }, - ) - } } + +fun checkIfLoadingComplete( + profiles: List, + profileImages: Map, + bannerImages: Map, + onComplete: () -> Unit +) { + val allProfilesLoaded = + profiles.all { profile -> + profileImages[profile.uid] != null && bannerImages[profile.uid] != null + } + if (allProfilesLoaded) onComplete() +} \ No newline at end of file diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/tools/ai/QuickFixAIChatScreen.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/tools/ai/QuickFixAIChatScreen.kt new file mode 100644 index 00000000..cd713a0f --- /dev/null +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/tools/ai/QuickFixAIChatScreen.kt @@ -0,0 +1,153 @@ +package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.tools.ai + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.arygm.quickfix.model.tools.ai.GeminiMessageModel +import com.arygm.quickfix.model.tools.ai.GeminiViewModel + +@Preview +@Composable +fun QuickFixAIChatScreen( + modifier: Modifier = Modifier, + viewModel: GeminiViewModel = GeminiViewModel() +) { + Column(modifier = modifier) { + MessageList(modifier = Modifier.weight(1f), messageList = viewModel.messageList) + + MessageInput { viewModel.sendMessage(it) } + } +} + +@Composable +fun MessageList(modifier: Modifier = Modifier, messageList: List) { + if (messageList.size <= 1) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = "Default Background", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(230.dp).zIndex(2f)) + + Spacer(modifier = Modifier.size(16.dp)) + + Text( + text = "How may I help?", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineLarge) + } + } else { + LazyColumn(modifier = modifier, reverseLayout = true) { + items(messageList.reversed()) { + if (messageList.indexOf(it) != 0) { + MessageRow(messageModel = it) + } + } + } + } +} + +@Composable +fun MessageRow(messageModel: GeminiMessageModel) { + val isModel = messageModel.role == "model" + + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier.align(if (isModel) Alignment.BottomStart else Alignment.BottomEnd) + .padding( + start = if (isModel) 8.dp else 70.dp, + end = if (isModel) 70.dp else 8.dp, + top = 8.dp, + bottom = 8.dp) + .clip(RoundedCornerShape(48f)) + .background( + if (isModel) MaterialTheme.colorScheme.tertiaryContainer + else MaterialTheme.colorScheme.primary) + .padding(16.dp)) { + SelectionContainer { + Text( + text = messageModel.message, + fontWeight = FontWeight.W500, + color = + if (isModel) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.surface, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +fun MessageInput(onMessageSend: (String) -> Unit) { + + var message by remember { mutableStateOf("") } + + Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + shape = RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(text = "Describe your issue") }, + value = message, + onValueChange = { message = it }, + trailingIcon = { + Surface( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(18.dp), + modifier = Modifier.size(40.dp).offset(x = (-4).dp)) { + IconButton( + onClick = { + if (message.isNotEmpty()) { + onMessageSend(message) + message = "" + } + }, + Modifier.size(20.dp).testTag("sendIcon")) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send") + } + } + }) + } +} diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/WorkerModeNavGraph.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/WorkerModeNavGraph.kt index 450278d4..41290821 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/WorkerModeNavGraph.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/WorkerModeNavGraph.kt @@ -17,44 +17,80 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.zIndex +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.arygm.quickfix.dataStore import com.arygm.quickfix.model.account.AccountViewModel +import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.offline.small.PreferencesRepositoryDataStore import com.arygm.quickfix.model.offline.small.PreferencesViewModel import com.arygm.quickfix.model.offline.small.PreferencesViewModelUserProfile +import com.arygm.quickfix.model.profile.ProfileViewModel +import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore +import com.arygm.quickfix.model.search.AnnouncementRepositoryFirestore +import com.arygm.quickfix.model.search.AnnouncementViewModel import com.arygm.quickfix.model.switchModes.ModeViewModel import com.arygm.quickfix.ui.elements.QuickFixOfflineBar import com.arygm.quickfix.ui.navigation.BottomNavigationMenu import com.arygm.quickfix.ui.navigation.NavigationActions -import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.getBottomBarIdUser +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.camera.QuickFixDisplayImages import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.profile.AccountConfigurationScreen import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.profile.WorkerProfileScreen +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.AnnouncementDetailScreen import com.arygm.quickfix.ui.uiMode.appContentUI.workerMode.announcements.AnnouncementsScreen import com.arygm.quickfix.ui.uiMode.appContentUI.workerMode.messages.MessagesScreen import com.arygm.quickfix.ui.uiMode.workerMode.home.HomeScreen import com.arygm.quickfix.ui.uiMode.workerMode.navigation.WORKER_TOP_LEVEL_DESTINATIONS import com.arygm.quickfix.ui.uiMode.workerMode.navigation.WorkerRoute import com.arygm.quickfix.ui.uiMode.workerMode.navigation.WorkerScreen +import com.arygm.quickfix.ui.uiMode.workerMode.navigation.getBottomBarIdWorker +import com.google.firebase.Firebase +import com.google.firebase.firestore.firestore +import com.google.firebase.storage.storage import kotlinx.coroutines.delay @Composable fun WorkerModeNavGraph( modeViewModel: ModeViewModel, + workerViewModel: ProfileViewModel, isOffline: Boolean, appContentNavigationActions: NavigationActions, preferencesViewModel: PreferencesViewModel, accountViewModel: AccountViewModel, + categoryViewModel: CategoryViewModel, rootMainNavigationActions: NavigationActions, userPreferencesViewModel: PreferencesViewModelUserProfile ) { + val context = LocalContext.current val workerNavController = rememberNavController() val workerNavigationActions = remember { NavigationActions(workerNavController) } + + // Create required repositories + val announcementRepository = + AnnouncementRepositoryFirestore(db = Firebase.firestore, storage = Firebase.storage) + val preferencesRepository = PreferencesRepositoryDataStore(context.dataStore) + val workerProfileRepository = + WorkerProfileRepositoryFirestore(db = Firebase.firestore, storage = Firebase.storage) + val announcementViewModel: AnnouncementViewModel = + viewModel( + factory = + AnnouncementViewModel.workerFactory( + announcementRepository = announcementRepository, + preferencesRepository = preferencesRepository, + workerProfileRepository = workerProfileRepository)) var currentScreen by remember { mutableStateOf(null) } val shouldShowBottomBar by remember { - derivedStateOf { currentScreen?.let { it != WorkerScreen.ACCOUNT_CONFIGURATION } ?: true } + derivedStateOf { + currentScreen?.let { it != WorkerScreen.ACCOUNT_CONFIGURATION } ?: true && + currentScreen?.let { + it != WorkerScreen.ANNOUNCEMENT_DETAIL && it != WorkerScreen.DISPLAY_IMAGES + } ?: true + } } val startDestination by modeViewModel.onSwitchStartDestWorker.collectAsState() var showBottomBar by remember { mutableStateOf(false) } @@ -88,7 +124,13 @@ fun WorkerModeNavGraph( MessagesNavHost(onScreenChange = { currentScreen = it }) } composable(WorkerRoute.ANNOUNCEMENT) { - AnnouncementsNavHost(onScreenChange = { currentScreen = it }) + AnnouncementsNavHost( + announcementViewModel = announcementViewModel, + preferencesViewModel = preferencesViewModel, + workerProfileViewModel = workerViewModel, + categoryViewModel = categoryViewModel, + accountViewModel = accountViewModel, + onScreenChange = { currentScreen = it }) } composable(WorkerRoute.PROFILE) { ProfileNavHost( @@ -118,7 +160,7 @@ fun WorkerModeNavGraph( }, navigationActions = workerNavigationActions, tabList = WORKER_TOP_LEVEL_DESTINATIONS, - getBottomBarId = getBottomBarIdUser) + getBottomBarId = getBottomBarIdWorker) } } } @@ -139,6 +181,11 @@ fun MessagesNavHost(onScreenChange: (String) -> Unit) { @Composable fun AnnouncementsNavHost( + announcementViewModel: AnnouncementViewModel, + preferencesViewModel: PreferencesViewModel, + workerProfileViewModel: ProfileViewModel, + categoryViewModel: CategoryViewModel, + accountViewModel: AccountViewModel, onScreenChange: (String) -> Unit, ) { val announcementsNavController = rememberNavController() @@ -150,7 +197,25 @@ fun AnnouncementsNavHost( navController = announcementsNavController, startDestination = WorkerScreen.ANNOUNCEMENT, ) { - composable(WorkerScreen.ANNOUNCEMENT) { AnnouncementsScreen() } + composable(WorkerScreen.ANNOUNCEMENT) { + AnnouncementsScreen( + announcementViewModel, + preferencesViewModel, + workerProfileViewModel, + categoryViewModel, + accountViewModel, + navigationActions) + } + composable(WorkerScreen.DISPLAY_IMAGES) { + QuickFixDisplayImages( + navigationActions = navigationActions, + preferencesViewModel = preferencesViewModel, + announcementViewModel = announcementViewModel) + } + composable(WorkerScreen.ANNOUNCEMENT_DETAIL) { + AnnouncementDetailScreen( + announcementViewModel, categoryViewModel, preferencesViewModel, navigationActions) + } } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/announcements/AnnouncementCard.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/announcements/AnnouncementCard.kt new file mode 100644 index 00000000..aad4c866 --- /dev/null +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/announcements/AnnouncementCard.kt @@ -0,0 +1,220 @@ +package com.arygm.quickfix.ui.uiMode.appContentUI.workerMode.announcements + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.arygm.quickfix.model.account.Account +import com.arygm.quickfix.model.account.AccountViewModel +import com.arygm.quickfix.model.category.Category +import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.category.getCategoryIcon +import com.arygm.quickfix.model.search.Announcement +import com.arygm.quickfix.ui.theme.poppinsTypography + +@Composable +fun AnnouncementCard( + modifier: Modifier = Modifier, + announcement: Announcement, + announcementImage: Bitmap?, + accountViewModel: AccountViewModel, + categoryViewModel: CategoryViewModel, + onClick: () -> Unit +) { + var category by remember { mutableStateOf(Category()) } + LaunchedEffect(Unit) { + categoryViewModel.getCategoryBySubcategoryId( + announcement.category, + onSuccess = { + if (it != null) { + category = it + } + }) + } + + var account by remember { mutableStateOf(null) } + LaunchedEffect(announcement.userId) { + accountViewModel.fetchUserAccount(announcement.userId) { fetchedAccount: Account? -> + account = fetchedAccount + } + } + + Card( + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = colorScheme.surface), + elevation = CardDefaults.cardElevation(10.dp), + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 10.dp, horizontal = 10.dp) + .clickable { onClick() } + .testTag("AnnouncementCard_${announcement.announcementId}")) { + Row( + modifier = + Modifier.fillMaxWidth() + .padding(8.dp) + .testTag("AnnouncementCardRow_${announcement.announcementId}"), + verticalAlignment = Alignment.CenterVertically) { + // Image Placeholder / Actual Image + if (announcementImage != null) { + Image( + bitmap = announcementImage.asImageBitmap(), + contentDescription = "Announcement Image", + contentScale = ContentScale.FillBounds, + modifier = + Modifier.clip(RoundedCornerShape(8.dp)) + .size(100.dp) + .aspectRatio(1f) + .background(colorScheme.onSurface.copy(alpha = 0.1f)) + .testTag("AnnouncementImage_${announcement.announcementId}")) + } else { + Box( + modifier = + Modifier.clip(RoundedCornerShape(8.dp)) + .size(100.dp) + .aspectRatio(1f) + .background(colorScheme.onSurface.copy(alpha = 0.1f)) + .testTag("AnnouncementImagePlaceholder_${announcement.announcementId}"), + contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = colorScheme.primary, + modifier = Modifier.testTag("Loader_${announcement.announcementId}")) + } + } + + Spacer( + modifier = + Modifier.width(8.dp) + .testTag("SpacerBetweenImageAndContent_${announcement.announcementId}")) + + Column( + modifier = + Modifier.weight(1f) + .testTag("AnnouncementContentColumn_${announcement.announcementId}")) { + // Title Row + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.testTag( + "AnnouncementTitleRow_${announcement.announcementId}")) { + Text( + text = announcement.title, + style = + poppinsTypography.bodyMedium.copy( + fontWeight = FontWeight.Bold, fontSize = 16.sp), + color = colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.testTag( + "AnnouncementTitle_${announcement.announcementId}")) + + Spacer( + modifier = + Modifier.weight(1f) + .testTag("TitleSpacer_${announcement.announcementId}")) + + // Category Icon + Icon( + imageVector = getCategoryIcon(category), + contentDescription = "Category Icon", + tint = colorScheme.primary, + modifier = + Modifier.size(20.dp) + .testTag( + "AnnouncementCategoryIcon_${announcement.announcementId}")) + } + + Spacer( + modifier = + Modifier.height(4.dp) + .testTag("SpacerAfterTitle_${announcement.announcementId}")) + + // Description + Text( + text = announcement.description, + style = poppinsTypography.bodySmall.copy(fontSize = 12.sp), + fontWeight = FontWeight.Normal, + color = colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.fillMaxWidth() + .padding(end = 16.dp) + .testTag("AnnouncementDescription_${announcement.announcementId}")) + + Spacer( + modifier = + Modifier.height(8.dp) + .testTag("SpacerAfterDescription_${announcement.announcementId}")) + + // Location Row + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.testTag( + "AnnouncementLocationRow_${announcement.announcementId}")) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = "Location Icon", + tint = colorScheme.onSurface, + modifier = + Modifier.size(14.dp) + .testTag( + "AnnouncementLocationIcon_${announcement.announcementId}")) + Spacer( + modifier = + Modifier.width(4.dp) + .testTag( + "SpacerInLocationRow_${announcement.announcementId}")) + Text( + text = announcement.location?.name ?: "Unknown", + style = poppinsTypography.bodySmall.copy(fontSize = 9.sp), + color = colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.width(60.dp) + .testTag( + "AnnouncementLocation_${announcement.announcementId}")) + Spacer( + modifier = + Modifier.weight(1f) + .testTag( + "SpacerAfterLocation_${announcement.announcementId}")) + + // Display User Name + Text( + text = + account?.let { + "By ${it.firstName} ${it.lastName.firstOrNull()?.uppercase() ?: ""}." + } ?: "By Unknown", + style = poppinsTypography.bodySmall.copy(fontSize = 9.sp), + color = colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.testTag( + "AnnouncementUserName_${announcement.announcementId}")) + } + } + } + } +} diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/announcements/AnnouncementsScreen.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/announcements/AnnouncementsScreen.kt index 568a65ba..d461a092 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/announcements/AnnouncementsScreen.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/announcements/AnnouncementsScreen.kt @@ -1,20 +1,299 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.workerMode.announcements +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.LocationSearching +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.arygm.quickfix.model.account.AccountViewModel +import com.arygm.quickfix.model.category.CategoryViewModel +import com.arygm.quickfix.model.offline.small.PreferencesViewModel +import com.arygm.quickfix.model.profile.ProfileViewModel +import com.arygm.quickfix.model.profile.WorkerProfile +import com.arygm.quickfix.model.search.AnnouncementViewModel +import com.arygm.quickfix.ui.elements.QuickFixButton +import com.arygm.quickfix.ui.elements.QuickFixLocationFilterBottomSheet +import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography +import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchFilterButtons +import com.arygm.quickfix.ui.uiMode.workerMode.navigation.WorkerScreen +import com.arygm.quickfix.utils.loadUserId @Composable -fun AnnouncementsScreen() { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally) { - Text("Announcements Screen", style = poppinsTypography.headlineLarge) - } +fun AnnouncementsScreen( + announcementViewModel: AnnouncementViewModel, + preferencesViewModel: PreferencesViewModel, + workerProfileViewModel: ProfileViewModel, + categoryViewModel: CategoryViewModel, + accountViewModel: AccountViewModel, + navigationActions: NavigationActions +) { + val context = LocalContext.current + var workerProfile by remember { mutableStateOf(null) } + var uid by remember { mutableStateOf("Loading...") } + + LaunchedEffect(Unit) { + uid = loadUserId(preferencesViewModel) + workerProfileViewModel.fetchUserProfile(uid) { profile -> + workerProfile = profile as WorkerProfile + } + } + + val announcements by announcementViewModel.announcements.collectAsState() + var filteredAnnouncements by remember { mutableStateOf(announcements) } + val imagesForAnnouncements by announcementViewModel.announcementImagesMap.collectAsState() + + var showFilterButtons by remember { mutableStateOf(false) } + + var locationFilterApplied by remember { mutableStateOf(false) } + var lastAppliedMaxDist by remember { mutableStateOf(200) } + var selectedLocationIndex by remember { mutableStateOf(null) } + + var phoneLocation by remember { + mutableStateOf(com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) + } + var baseLocation by remember { mutableStateOf(phoneLocation) } + var selectedLocation by remember { mutableStateOf(com.arygm.quickfix.model.locations.Location()) } + var maxDistance by remember { mutableStateOf(0) } + var showLocationBottomSheet by remember { mutableStateOf(false) } + + fun reapplyFilters() { + var updatedAnnouncements = announcements + + if (locationFilterApplied) { + updatedAnnouncements = + announcementViewModel.filterAnnouncementsByDistance( + updatedAnnouncements, selectedLocation, maxDistance) + } + + filteredAnnouncements = updatedAnnouncements + } + + val listOfButtons = + listOf( + SearchFilterButtons( + onClick = { + filteredAnnouncements = announcements + locationFilterApplied = false + lastAppliedMaxDist = 200 + selectedLocationIndex = null + baseLocation = phoneLocation + }, + text = "Clear", + leadingIcon = Icons.Default.Clear, + applied = false), + SearchFilterButtons( + onClick = { showLocationBottomSheet = true }, + text = "Location", + leadingIcon = Icons.Default.LocationSearching, + trailingIcon = Icons.Default.KeyboardArrowDown, + applied = locationFilterApplied)) + + BoxWithConstraints(modifier = Modifier.fillMaxSize().testTag("announcements_screen")) { + val screenHeight = maxHeight + val screenWidth = maxWidth + Scaffold(modifier = Modifier.testTag("announcements_scaffold")) { paddingValues -> + Column( + modifier = + Modifier.fillMaxWidth() + .padding(paddingValues) + .padding(top = screenHeight * 0.02f) + .testTag("main_column"), + horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = Modifier.fillMaxWidth().testTag("title_column"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top) { + Text( + text = "Announcements for you", + style = poppinsTypography.labelMedium, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + modifier = Modifier.testTag("announcements_title")) + Text( + text = "Here are announcements that matches your profile", + style = poppinsTypography.labelSmall, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + color = colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.testTag("announcements_subtitle")) + } + + Row( + modifier = + Modifier.fillMaxWidth() + .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) + .padding(horizontal = screenWidth * 0.02f) + .wrapContentHeight() + .testTag("filter_buttons_row"), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { showFilterButtons = !showFilterButtons }, + modifier = Modifier.padding(bottom = screenHeight * 0.01f).testTag("tuneButton"), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = + if (showFilterButtons) colorScheme.primary else colorScheme.surface), + content = { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = "Filter", + tint = + if (showFilterButtons) colorScheme.onPrimary + else colorScheme.onBackground, + modifier = Modifier.testTag("tuneIcon")) + }) + + Spacer(modifier = Modifier.width(10.dp).testTag("filter_spacer")) + + AnimatedVisibility( + visible = showFilterButtons, + modifier = Modifier.testTag("animated_filter_visibility")) { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.testTag("lazy_filter_row")) { + items(listOfButtons.size) { index -> + val buttonData = listOfButtons[index] + QuickFixButton( + buttonText = buttonData.text, + onClickAction = buttonData.onClick, + buttonColor = + if (buttonData.applied) colorScheme.primary + else colorScheme.surface, + textColor = + if (buttonData.applied) colorScheme.onPrimary + else colorScheme.onBackground, + textStyle = + poppinsTypography.labelSmall.copy( + fontWeight = FontWeight.Medium), + height = screenHeight * 0.05f, + leadingIcon = buttonData.leadingIcon, + trailingIcon = buttonData.trailingIcon, + leadingIconTint = + if (buttonData.applied) colorScheme.onPrimary + else colorScheme.onBackground, + trailingIconTint = + if (buttonData.applied) colorScheme.onPrimary + else colorScheme.onBackground, + contentPadding = + PaddingValues( + vertical = 0.dp, horizontal = screenWidth * 0.02f), + modifier = Modifier.testTag("filter_button_${buttonData.text}")) + Spacer( + modifier = + Modifier.width(screenHeight * 0.01f) + .testTag("filter_button_spacer_$index")) + } + } + } + } + + LazyColumn(modifier = Modifier.fillMaxWidth().testTag("worker_profiles_list")) { + items(filteredAnnouncements.size) { index -> + val announcement = filteredAnnouncements[index] + + LaunchedEffect(Unit) { + announcementViewModel.fetchAnnouncementImagesAsBitmaps( + announcement.announcementId) + } + + val pairs = imagesForAnnouncements[announcement.announcementId] ?: emptyList() + val bitmapToDisplay = pairs.firstOrNull()?.second + + AnnouncementCard( + modifier = Modifier.testTag("announcement_$index"), + announcement = announcement, + announcementImage = bitmapToDisplay, + accountViewModel = accountViewModel, + categoryViewModel = categoryViewModel) { + announcementViewModel.selectAnnouncement(announcement) + navigationActions.navigateTo(WorkerScreen.ANNOUNCEMENT_DETAIL) + } + + Spacer( + modifier = + Modifier.height(screenHeight * 0.004f) + .testTag("announcement_spacer_$index")) + } + } + } + } + + workerProfile?.let { + QuickFixLocationFilterBottomSheet( + showModalBottomSheet = showLocationBottomSheet, + profile = it, + phoneLocation = phoneLocation, + selectedLocationIndex = selectedLocationIndex, + onApplyClick = { location, max -> + selectedLocation = location + lastAppliedMaxDist = max + baseLocation = location + maxDistance = max + + if (location == com.arygm.quickfix.model.locations.Location(0.0, 0.0, "Default")) { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } + if (locationFilterApplied) { + reapplyFilters() + } else { + filteredAnnouncements = + announcementViewModel.filterAnnouncementsByDistance( + filteredAnnouncements, location, max) + } + locationFilterApplied = true + }, + onDismissRequest = { showLocationBottomSheet = false }, + onClearClick = { + baseLocation = phoneLocation + lastAppliedMaxDist = 200 + selectedLocation = com.arygm.quickfix.model.locations.Location() + maxDistance = 0 + locationFilterApplied = false + reapplyFilters() + }, + clearEnabled = locationFilterApplied, + end = lastAppliedMaxDist) + } + } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/navigation/WorkerNavigation.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/navigation/WorkerNavigation.kt index ed270679..c3f175d1 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/navigation/WorkerNavigation.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/workerMode/navigation/WorkerNavigation.kt @@ -21,6 +21,8 @@ object WorkerScreen { const val MESSAGES = "Messages Screen" const val PROFILE = "Profile Screen" const val ACCOUNT_CONFIGURATION = "Account configuration Screen" + const val DISPLAY_IMAGES = "Displayed images Screen" + const val ANNOUNCEMENT_DETAIL = "Announcement detail Screen" } object WorkerTopLevelDestinations { diff --git a/app/src/main/java/com/arygm/quickfix/utils/Preferences.kt b/app/src/main/java/com/arygm/quickfix/utils/Preferences.kt index 23307152..0a079ca4 100644 --- a/app/src/main/java/com/arygm/quickfix/utils/Preferences.kt +++ b/app/src/main/java/com/arygm/quickfix/utils/Preferences.kt @@ -19,6 +19,7 @@ val IS_SIGN_IN_KEY = booleanPreferencesKey("is_sign_in") val UID_KEY = stringPreferencesKey("user_id") val FIRST_NAME_KEY = stringPreferencesKey("first_name") val LAST_NAME_KEY = stringPreferencesKey("last_name") +val PROFILE_PICTURE_KEY = stringPreferencesKey("profile_picture") val EMAIL_KEY = stringPreferencesKey("email") val BIRTH_DATE_KEY = stringPreferencesKey("date_of_birth") val IS_WORKER_KEY = booleanPreferencesKey("is_worker") @@ -41,6 +42,7 @@ fun setAccountPreferences( preferencesViewModel.savePreference(EMAIL_KEY, account.email) preferencesViewModel.savePreference(BIRTH_DATE_KEY, timestampToString(account.birthDate)) preferencesViewModel.savePreference(IS_WORKER_KEY, account.isWorker) + preferencesViewModel.savePreference(PROFILE_PICTURE_KEY, account.profilePicture) } } @@ -137,6 +139,16 @@ fun setIsWorker( CoroutineScope(dispatcher).launch { preferencesViewModel.savePreference(IS_WORKER_KEY, isWorker) } } +fun setProfilePicture( + preferencesViewModel: PreferencesViewModel, + profilePicture: String, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) { + CoroutineScope(dispatcher).launch { + preferencesViewModel.savePreference(PROFILE_PICTURE_KEY, profilePicture) + } +} + suspend fun loadWallet(preferencesViewModel: PreferencesViewModel): Double { return suspendCoroutine { cont -> var resumed = false @@ -187,6 +199,18 @@ suspend fun loadFirstName(preferencesViewModel: PreferencesViewModel): String { } } +suspend fun loadProfilePicture(preferencesViewModel: PreferencesViewModel): String { + return suspendCoroutine { cont -> + var resumed = false + preferencesViewModel.loadPreference(PROFILE_PICTURE_KEY) { value -> + if (!resumed) { + resumed = true + cont.resume(value ?: "no_profile_picture") + } + } + } +} + suspend fun loadLastName(preferencesViewModel: PreferencesViewModel): String { return suspendCoroutine { cont -> var resumed = false diff --git a/app/src/test/java/com/arygm/quickfix/model/account/AccountRepositoryFirestoreTest.kt b/app/src/test/java/com/arygm/quickfix/model/account/AccountRepositoryFirestoreTest.kt index 3738151a..fa60540f 100644 --- a/app/src/test/java/com/arygm/quickfix/model/account/AccountRepositoryFirestoreTest.kt +++ b/app/src/test/java/com/arygm/quickfix/model/account/AccountRepositoryFirestoreTest.kt @@ -1,7 +1,11 @@ package com.arygm.quickfix.model.account +import android.graphics.Bitmap +import android.net.Uri import android.os.Looper import androidx.test.core.app.ApplicationProvider +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener import com.google.android.gms.tasks.TaskCompletionSource import com.google.android.gms.tasks.Tasks import com.google.firebase.FirebaseApp @@ -14,6 +18,10 @@ import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import com.google.firebase.firestore.QuerySnapshot +import com.google.firebase.storage.FirebaseStorage +import com.google.firebase.storage.StorageReference +import com.google.firebase.storage.UploadTask +import java.io.ByteArrayOutputStream import junit.framework.TestCase.fail import org.junit.After import org.junit.Assert.assertEquals @@ -24,10 +32,12 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock import org.mockito.MockedStatic import org.mockito.Mockito import org.mockito.Mockito.doNothing +import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.mockito.kotlin.any @@ -53,6 +63,12 @@ class AccountRepositoryFirestoreTest { @Mock private lateinit var mockAccountQuerySnapshot: QuerySnapshot @Mock private lateinit var mockQuery: Query + // Firebase Storage Mocks + @Mock private lateinit var mockStorage: FirebaseStorage + @Mock private lateinit var storageRef: StorageReference + @Mock private lateinit var storageRef1: StorageReference + @Mock private lateinit var storageRef2: StorageReference + @Mock private lateinit var accountFolderRef: StorageReference private lateinit var mockFirebaseAuth: FirebaseAuth private lateinit var firebaseAuthMockedStatic: MockedStatic @@ -66,7 +82,8 @@ class AccountRepositoryFirestoreTest { lastName = "Doe", email = "john.doe@example.com", birthDate = Timestamp.now(), - isWorker = false) + isWorker = false, + profilePicture = "https://example.com/profile.jpg") private val account2 = Account( @@ -75,7 +92,8 @@ class AccountRepositoryFirestoreTest { lastName = "Smith", email = "jane.smith@example.com", birthDate = Timestamp.now(), - isWorker = true) + isWorker = true, + profilePicture = "https://example.com/profile2.jpg") @Before fun setUp() { @@ -92,8 +110,11 @@ class AccountRepositoryFirestoreTest { firebaseAuthMockedStatic .`when` { FirebaseAuth.getInstance() } .thenReturn(mockFirebaseAuth) - - accountRepositoryFirestore = AccountRepositoryFirestore(mockFirestore) + `when`(mockStorage.reference).thenReturn(storageRef) + `when`(storageRef.child(anyString())).thenReturn(storageRef1) + `when`(storageRef1.child(anyString())).thenReturn(storageRef2) + `when`(storageRef2.child(anyString())).thenReturn(accountFolderRef) + accountRepositoryFirestore = AccountRepositoryFirestore(mockFirestore, mockStorage) `when`(mockFirestore.collection(any())).thenReturn(mockCollectionReference) `when`(mockCollectionReference.document(any())).thenReturn(mockDocumentReference) @@ -113,6 +134,96 @@ class AccountRepositoryFirestoreTest { firebaseAuthMockedStatic.close() } + @Test + fun uploadAccountImages_whenSuccess_callsOnSuccess() { + val accountId = "1" + val bitmaps = listOf(mock(Bitmap::class.java)) + val expectedUrl = listOf("https://example.com/uploaded_image.jpg") + val baos = ByteArrayOutputStream() + + // Simuler la compression d'image + bitmaps.forEach { bitmap -> bitmap.compress(Bitmap.CompressFormat.JPEG, 90, baos) } + val imageData = baos.toByteArray() + + // Mock StorageReference + `when`(storageRef.child("accounts").child(accountId)).thenReturn(accountFolderRef) + val fileRef = mock(StorageReference::class.java) + + `when`(accountFolderRef.child(anyString())).thenReturn(fileRef) + + // Mock putBytes + val mockUploadTask = mock(UploadTask::class.java) + `when`(fileRef.putBytes(eq(imageData))).thenReturn(mockUploadTask) + + // Mock addOnSuccessListener + `when`(mockUploadTask.addOnSuccessListener(org.mockito.kotlin.any())).thenAnswer { invocation -> + val listener = invocation.getArgument>(0) + val taskSnapshot = mock(UploadTask.TaskSnapshot::class.java) // Mock the snapshot + listener.onSuccess(taskSnapshot) + mockUploadTask + } + + // Mock fileRef.downloadUrl + `when`(fileRef.downloadUrl).thenReturn(Tasks.forResult(Uri.parse(expectedUrl[0]))) + + var resultUrl = listOf() + accountRepositoryFirestore.uploadAccountImages( + accountId = accountId, + images = bitmaps, + onSuccess = { urls -> + resultUrl = urls + assertEquals(expectedUrl, resultUrl) + }, + onFailure = { fail("Failure callback should not be called") }) + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun uploadAccountImages_whenFailure_callsOnFailure() { + val accountId = "1" + val bitmaps = listOf(mock(Bitmap::class.java)) + val exception = Exception("Upload failed") + + // Mock StorageReference + `when`(storageRef.child("accounts").child(accountId)).thenReturn(accountFolderRef) + val fileRef = mock(StorageReference::class.java) + `when`(accountFolderRef.child(anyString())).thenReturn(fileRef) + + // Mock putBytes + val mockUploadTask = mock(UploadTask::class.java) + `when`(fileRef.putBytes(any())).thenReturn(mockUploadTask) + + // Mock addOnFailureListener + `when`(mockUploadTask.addOnFailureListener(org.mockito.kotlin.any())).thenAnswer { invocation -> + val listener = invocation.arguments[0] as OnFailureListener + listener.onFailure(exception) // Simule une erreur d'upload + mockUploadTask + } + + // Mock addOnSuccessListener pour éviter des comportements inattendus + `when`(mockUploadTask.addOnSuccessListener(org.mockito.kotlin.any())).thenReturn(mockUploadTask) + + // Act + var onFailureCalled = false + var exceptionReceived: Exception? = null + accountRepositoryFirestore.uploadAccountImages( + accountId = accountId, + images = bitmaps, + onSuccess = { fail("onSuccess should not be called when upload fails") }, + onFailure = { e -> + onFailureCalled = true + exceptionReceived = e + }) + + // Attendre que les tâches se terminent + shadowOf(Looper.getMainLooper()).idle() + + // Assertions + assertTrue(onFailureCalled) + assertNotNull(exceptionReceived) + assertEquals(exception, exceptionReceived) + } + // ----- CRUD Operation Tests ----- @Test @@ -306,6 +417,8 @@ class AccountRepositoryFirestoreTest { `when`(mockDocumentSnapshot.getString("lastName")).thenReturn(account.lastName) `when`(mockDocumentSnapshot.getString("email")).thenReturn(account.email) `when`(mockDocumentSnapshot.getTimestamp("birthDate")).thenReturn(account.birthDate) + `when`(mockDocumentSnapshot.getBoolean("worker")).thenReturn(account.isWorker) + `when`(mockDocumentSnapshot.getString("profilePicture")).thenReturn(account.profilePicture) var callbackCalled = false @@ -381,6 +494,8 @@ class AccountRepositoryFirestoreTest { `when`(mockDocumentSnapshot.getString("lastName")).thenReturn(account.lastName) `when`(mockDocumentSnapshot.getString("email")).thenReturn(account.email) `when`(mockDocumentSnapshot.getTimestamp("birthDate")).thenReturn(account.birthDate) + `when`(mockDocumentSnapshot.getBoolean("worker")).thenReturn(account.isWorker) + `when`(mockDocumentSnapshot.getString("profilePicture")).thenReturn(account.profilePicture) var callbackCalled = false @@ -462,6 +577,7 @@ class AccountRepositoryFirestoreTest { `when`(document1.getString("email")).thenReturn(account.email) `when`(document1.getTimestamp("birthDate")).thenReturn(account.birthDate) `when`(document1.getBoolean("worker")).thenReturn(account.isWorker) + `when`(document1.getString("profilePicture")).thenReturn(account.profilePicture) // Mock data for second document `when`(document2.id).thenReturn(account2.uid) @@ -470,6 +586,7 @@ class AccountRepositoryFirestoreTest { `when`(document2.getString("email")).thenReturn(account2.email) `when`(document2.getTimestamp("birthDate")).thenReturn(account2.birthDate) `when`(document2.getBoolean("worker")).thenReturn(account2.isWorker) + `when`(document2.getString("profilePicture")).thenReturn(account2.profilePicture) var callbackCalled = false var returnedAccounts: List? = null @@ -528,6 +645,8 @@ class AccountRepositoryFirestoreTest { `when`(document.getString("lastName")).thenReturn(account.lastName) `when`(document.getString("email")).thenReturn(account.email) `when`(document.getTimestamp("birthDate")).thenReturn(account.birthDate) + `when`(document.getBoolean("worker")).thenReturn(account.isWorker) + `when`(document.getString("profilePicture")).thenReturn(account.profilePicture) // Act val result = invokeDocumentToAccount(document) @@ -599,6 +718,7 @@ class AccountRepositoryFirestoreTest { `when`(document.getTimestamp("birthDate")).thenReturn(account.birthDate) // Extra field that is not used by the repository `when`(document.getString("isWorker")).thenReturn("false") + `when`(document.getString("profilePicture")).thenReturn(account.profilePicture) // Act val result = invokeDocumentToAccount(document) diff --git a/app/src/test/java/com/arygm/quickfix/model/account/AccountViewModelTest.kt b/app/src/test/java/com/arygm/quickfix/model/account/AccountViewModelTest.kt index 60e4ed18..86af1fd3 100644 --- a/app/src/test/java/com/arygm/quickfix/model/account/AccountViewModelTest.kt +++ b/app/src/test/java/com/arygm/quickfix/model/account/AccountViewModelTest.kt @@ -14,6 +14,8 @@ import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import com.google.firebase.firestore.QuerySnapshot +import com.google.firebase.storage.FirebaseStorage +import com.google.firebase.storage.StorageReference import junit.framework.TestCase.fail import org.junit.After import org.junit.Assert.assertEquals @@ -53,6 +55,11 @@ class AccountViewModelTest { @Mock private lateinit var mockAccountQuerySnapshot: QuerySnapshot @Mock private lateinit var mockQuery: Query + @Mock private lateinit var mockStorage: FirebaseStorage + @Mock private lateinit var storageRef: StorageReference + @Mock private lateinit var storageRef1: StorageReference + @Mock private lateinit var storageRef2: StorageReference + @Mock private lateinit var accountFolderRef: StorageReference private lateinit var mockFirebaseAuth: FirebaseAuth private lateinit var firebaseAuthMockedStatic: MockedStatic @@ -66,7 +73,8 @@ class AccountViewModelTest { lastName = "Doe", email = "john.doe@example.com", birthDate = Timestamp.now(), - isWorker = false) + isWorker = false, + profilePicture = "https://example.com/profile.jpg") private val account2 = Account( @@ -75,7 +83,8 @@ class AccountViewModelTest { lastName = "Smith", email = "jane.smith@example.com", birthDate = Timestamp.now(), - isWorker = true) + isWorker = true, + profilePicture = "https://example.com/profile2.jpg") @Before fun setUp() { @@ -92,8 +101,9 @@ class AccountViewModelTest { firebaseAuthMockedStatic .`when` { FirebaseAuth.getInstance() } .thenReturn(mockFirebaseAuth) + `when`(mockStorage.reference).thenReturn(storageRef) - accountRepositoryFirestore = AccountRepositoryFirestore(mockFirestore) + accountRepositoryFirestore = AccountRepositoryFirestore(mockFirestore, mockStorage) // Mocking the collection reference `when`(mockFirestore.collection(eq("accounts"))).thenReturn(mockCollectionReference) @@ -307,6 +317,8 @@ class AccountViewModelTest { `when`(mockDocumentSnapshot.getString("lastName")).thenReturn(account.lastName) `when`(mockDocumentSnapshot.getString("email")).thenReturn(account.email) `when`(mockDocumentSnapshot.getTimestamp("birthDate")).thenReturn(account.birthDate) + `when`(mockDocumentSnapshot.getBoolean("worker")).thenReturn(account.isWorker) + `when`(mockDocumentSnapshot.getString("profilePicture")).thenReturn(account.profilePicture) var callbackCalled = false @@ -385,6 +397,8 @@ class AccountViewModelTest { `when`(mockDocumentSnapshot.getString("lastName")).thenReturn(account.lastName) `when`(mockDocumentSnapshot.getString("email")).thenReturn(account.email) `when`(mockDocumentSnapshot.getTimestamp("birthDate")).thenReturn(account.birthDate) + `when`(mockDocumentSnapshot.getBoolean("worker")).thenReturn(account.isWorker) + `when`(mockDocumentSnapshot.getString("profilePicture")).thenReturn(account.profilePicture) var callbackCalled = false @@ -472,6 +486,7 @@ class AccountViewModelTest { `when`(document1.getString("email")).thenReturn(account.email) `when`(document1.getTimestamp("birthDate")).thenReturn(account.birthDate) `when`(document1.getBoolean("worker")).thenReturn(account.isWorker) + `when`(document1.getString("profilePicture")).thenReturn(account.profilePicture) // Mock data for second document `when`(document2.id).thenReturn(account2.uid) @@ -480,6 +495,7 @@ class AccountViewModelTest { `when`(document2.getString("email")).thenReturn(account2.email) `when`(document2.getTimestamp("birthDate")).thenReturn(account2.birthDate) `when`(document2.getBoolean("worker")).thenReturn(account2.isWorker) + `when`(document2.getString("profilePicture")).thenReturn(account2.profilePicture) var callbackCalled = false var returnedAccounts: List? = null @@ -538,6 +554,8 @@ class AccountViewModelTest { `when`(document.getString("lastName")).thenReturn(account.lastName) `when`(document.getString("email")).thenReturn(account.email) `when`(document.getTimestamp("birthDate")).thenReturn(account.birthDate) + `when`(document.getBoolean("worker")).thenReturn(account.isWorker) + `when`(document.getString("profilePicture")).thenReturn(account.profilePicture) // Act val result = invokeDocumentToAccount(document) @@ -609,6 +627,7 @@ class AccountViewModelTest { `when`(document.getTimestamp("birthDate")).thenReturn(account.birthDate) // Extra field that is not used by the repository `when`(document.getString("isWorker")).thenReturn("false") + `when`(document.getString("profilePicture")).thenReturn(account.profilePicture) // Act val result = invokeDocumentToAccount(document) diff --git a/app/src/test/java/com/arygm/quickfix/model/profile/SearchViewModelTest.kt b/app/src/test/java/com/arygm/quickfix/model/profile/SearchViewModelTest.kt index 267f883b..abf45abd 100644 --- a/app/src/test/java/com/arygm/quickfix/model/profile/SearchViewModelTest.kt +++ b/app/src/test/java/com/arygm/quickfix/model/profile/SearchViewModelTest.kt @@ -8,7 +8,6 @@ import com.arygm.quickfix.model.profile.dataFields.Review import com.arygm.quickfix.model.search.SearchViewModel import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse -import junit.framework.TestCase.assertNull import junit.framework.TestCase.assertTrue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -19,12 +18,9 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Test -import org.mockito.ArgumentMatchers.eq -import org.mockito.ArgumentMatchers.isNull import org.mockito.Mockito.doAnswer import org.mockito.Mockito.mock import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull class SearchViewModelTest { private lateinit var viewModel: SearchViewModel @@ -50,224 +46,6 @@ class SearchViewModelTest { Dispatchers.resetMain() } - @Test - fun testFilterWorkerProfilesByHourlyRateSuccess() { - // Arrange: Prepare expected worker profile and mock repository success behavior - val expectedProfiles = - listOf( - WorkerProfile( - uid = "worker_123", - price = 25.0, - fieldOfWork = "Plumber", - location = Location(46.0, 6.0, "Test Location"))) - - // Mock repository behavior for a successful response - doAnswer { invocation -> - val onSuccess = invocation.arguments[6] as (List) -> Unit - onSuccess(expectedProfiles) // Simulate success callback - null - } - .`when`(mockRepository) - .filterWorkers( - anyOrNull(), - anyOrNull(), - eq(30.0), - isNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull()) - - // Act: Call ViewModel's function to filter worker profiles - viewModel.filterWorkerProfiles(price = 30.0) - - // Assert: Check that the ViewModel state is updated correctly - assertEquals(expectedProfiles, viewModel.workerProfiles.value) - assertNull(viewModel.errorMessage.value) // Ensure there's no error message - } - - @Test - fun testFilterWorkerProfilesByHourlyRateFailure() { - // Arrange: Mock an error message and simulate repository failure behavior - val errorMessage = "Failed to fetch profiles" - - // Mock repository behavior for a failure response - doAnswer { invocation -> - val onFailure = invocation.arguments[7] as (Exception) -> Unit - onFailure(Exception(errorMessage)) // Simulate failure callback - null - } - .`when`(mockRepository) - .filterWorkers( - anyOrNull(), - anyOrNull(), - eq(30.0), - isNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull()) - - // Act: Call ViewModel's function to filter worker profiles - viewModel.filterWorkerProfiles(price = 30.0) - - // Assert: Check that the ViewModel state is updated correctly on failure - assertTrue(viewModel.workerProfiles.value.isEmpty()) // The list should be empty - assertEquals(errorMessage, viewModel.errorMessage.value) // Ensure error message is set - } - - @Test - fun testFilterWorkerProfilesByFieldOfWorkSuccess() { - // Arrange: Prepare expected worker profiles and mock repository success behavior - val expectedProfiles = - listOf( - WorkerProfile( - uid = "worker_124", - price = 40.0, - fieldOfWork = "Electrician", - location = Location(46.0, 7.0, "Another Test Location"))) - - // Mock repository behavior for a successful response - doAnswer { invocation -> - val onSuccess = invocation.arguments[6] as (List) -> Unit - onSuccess(expectedProfiles) // Simulate success callback - null - } - .`when`(mockRepository) - .filterWorkers( - anyOrNull(), - anyOrNull(), - isNull(), - eq("Electrician"), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull()) - - // Act: Call ViewModel's function to filter worker profiles by field of work - viewModel.filterWorkerProfiles(fieldOfWork = "Electrician") - - // Assert: Check that the ViewModel state is updated correctly - assertEquals(expectedProfiles, viewModel.workerProfiles.value) - assertNull(viewModel.errorMessage.value) - } - - @Test - fun testFilterWorkerProfilesByDistanceSuccess() { - // Arrange: Prepare expected worker profiles - val profiles = - listOf( - WorkerProfile( - uid = "worker_125", - price = 30.0, - fieldOfWork = "Handyman", - location = Location(46.5, 6.6, "Nearby Location"))) - - // Mock repository behavior - doAnswer { invocation -> - val onSuccess = invocation.arguments[6] as (List) -> Unit - onSuccess(profiles) // Simulate success callback - null - } - .`when`(mockRepository) - .filterWorkers( - isNull(), - anyOrNull(), - isNull(), - isNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull()) - - // Act: Filter profiles by distance (user's location vs worker's location) - viewModel.filterWorkerProfiles( - location = Location(46.0, 6.0, "User Location"), maxDistanceInKm = 100.0) - - // Assert: Ensure the profiles list contains the correct workers within the distance - val result = viewModel.workerProfiles.value - assertEquals(1, result.size) - assertEquals("worker_125", result[0].uid) - } - - @Test - fun testFilterWorkerProfilesWithAllNullParameters() { - // Arrange: Prepare worker profiles - val profiles = - listOf( - WorkerProfile( - uid = "worker_126", - price = 20.0, - fieldOfWork = "Gardening", - location = Location(45.5, 6.0, "Far Away Location"))) - - // Mock repository behavior for a successful response - doAnswer { invocation -> - val onSuccess = invocation.arguments[6] as (List) -> Unit - onSuccess(profiles) // Simulate success callback - null - } - .`when`(mockRepository) - .filterWorkers( - isNull(), - anyOrNull(), - isNull(), - isNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull()) - - // Act: Call ViewModel's function with all null parameters - viewModel.filterWorkerProfiles() - - // Assert: Check that all profiles are returned without any filtering - assertEquals(profiles, viewModel.workerProfiles.value) - } - - @Test - fun testFilterWorkerProfilesWithMultipleFilters() { - // Arrange: Prepare worker profiles - val profiles = - listOf( - WorkerProfile( - uid = "worker_127", - price = 45.0, - fieldOfWork = "Journalist", - location = Location(47.0, 8.0, "Metropolis")), - WorkerProfile( - uid = "worker_128", - price = 80.0, - fieldOfWork = "CEO", - location = Location(40.0, -74.0, "Gotham City"))) - - // Mock repository behavior for a successful response - doAnswer { invocation -> - val onSuccess = invocation.arguments[6] as (List) -> Unit - onSuccess(profiles) // Simulate success callback - null - } - .`when`(mockRepository) - .filterWorkers( - isNull(), - anyOrNull(), - eq(50.0), - isNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull()) - - // Act: Apply multiple filters - viewModel.filterWorkerProfiles( - price = 50.0, location = Location(46.0, 6.0, "User Location"), maxDistanceInKm = 500.0) - - // Assert: Only "worker_127" should match the filters - val result = viewModel.workerProfiles.value - assertEquals(1, result.size) - assertEquals("worker_127", result[0].uid) - } - @Test fun testFilterWorkersBySubcategorySuccess() { // Arrange: Prepare expected worker profiles diff --git a/app/src/test/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestoreImageFetchTest.kt b/app/src/test/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestoreImageFetchTest.kt new file mode 100644 index 00000000..9b465202 --- /dev/null +++ b/app/src/test/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestoreImageFetchTest.kt @@ -0,0 +1,339 @@ +package com.arygm.quickfix.model.profile + +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.storage.FirebaseStorage +import com.google.firebase.storage.StorageReference +import java.io.ByteArrayOutputStream +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.eq +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class UserProfileRepositoryFirestoreImageFetchTest { + + @Mock private lateinit var mockFirestore: FirebaseFirestore + @Mock private lateinit var mockDocumentReference: DocumentReference + @Mock private lateinit var mockCollectionReference: CollectionReference + @Mock private lateinit var mockDocumentSnapshot: DocumentSnapshot + @Mock private lateinit var mockStorage: FirebaseStorage + @Mock private lateinit var mockStorageRef: StorageReference + @Mock private lateinit var mockImageRef: StorageReference + + private lateinit var mockFirebaseAuth: FirebaseAuth + private lateinit var firebaseAuthMockedStatic: MockedStatic + private lateinit var repository: UserProfileRepositoryFirestore + + private val accountId = "testAccountId" + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + if (FirebaseApp.getApps(ApplicationProvider.getApplicationContext()).isEmpty()) { + FirebaseApp.initializeApp(ApplicationProvider.getApplicationContext()) + } + + firebaseAuthMockedStatic = Mockito.mockStatic(FirebaseAuth::class.java) + mockFirebaseAuth = Mockito.mock(FirebaseAuth::class.java) + firebaseAuthMockedStatic + .`when` { FirebaseAuth.getInstance() } + .thenReturn(mockFirebaseAuth) + + `when`(mockFirestore.collection(anyString())).thenReturn(mockCollectionReference) + `when`(mockCollectionReference.document(anyString())).thenReturn(mockDocumentReference) + + `when`(mockStorage.reference).thenReturn(mockStorageRef) + + repository = UserProfileRepositoryFirestore(mockFirestore, mockStorage) + } + + @After + fun tearDown() { + firebaseAuthMockedStatic.close() + } + + // Helper method to create a bitmap as returned data + private fun createTestBitmap(): Bitmap { + val width = 10 + val height = 10 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.GREEN) + return bitmap + } + + // --- fetchProfileImageAsBitmap tests --- + + @Test + fun fetchProfileImageAsBitmap_emptyUrl_returnsFallbackImage() { + // Firestore returns empty URL + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("profileImageUrl")).thenReturn("") + tcs.setResult(mockDocumentSnapshot) + + var onSuccessCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchProfileImageAsBitmap_urlContainsLocalEmulator_returnsFallbackImage() { + // Firestore returns a URL containing "10.0.2.2:9199" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("profileImageUrl")) + .thenReturn("http://10.0.2.2:9199/someimage.jpg") + tcs.setResult(mockDocumentSnapshot) + + var onSuccessCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchProfileImageAsBitmap_validUrl_fetchesFromStorageSuccess() { + // Firestore returns a valid URL + val imageUrl = "https://firebasestorage.googleapis.com/v0/b/test/o/someimage.jpg" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("profileImageUrl")).thenReturn(imageUrl) + tcs.setResult(mockDocumentSnapshot) + + // Mock storage fetch + `when`(mockStorage.getReferenceFromUrl(eq(imageUrl))).thenReturn(mockImageRef) + val testBitmap = createTestBitmap() + val baos = ByteArrayOutputStream() + testBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos) + val bytes = baos.toByteArray() + + val tcsGetBytes = TaskCompletionSource() + `when`(mockImageRef.getBytes(Long.MAX_VALUE)).thenReturn(tcsGetBytes.task) + + var onSuccessCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + // Check if bitmap matches what we "returned" + // We won't decode it again here, but we can at least assert non-null + }, + onFailure = { fail("Should not fail") }) + + tcsGetBytes.setResult(bytes) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchProfileImageAsBitmap_validUrl_fetchesFromStorageFailure() { + // Firestore returns a valid URL + val imageUrl = "https://firebasestorage.googleapis.com/v0/b/test/o/someimage.jpg" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("profileImageUrl")).thenReturn(imageUrl) + tcs.setResult(mockDocumentSnapshot) + + // Mock storage fetch failure + `when`(mockStorage.getReferenceFromUrl(eq(imageUrl))).thenReturn(mockImageRef) + val tcsGetBytes = TaskCompletionSource() + `when`(mockImageRef.getBytes(Long.MAX_VALUE)).thenReturn(tcsGetBytes.task) + + var onFailureCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { fail("Should not succeed") }, + onFailure = { + onFailureCalled = true + assertTrue(it is Exception) + }) + + tcsGetBytes.setException(Exception("Storage error")) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onFailureCalled) + } + + @Test + fun fetchProfileImageAsBitmap_firestoreFailure() { + // Firestore get fails + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + + var onFailureCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { fail("Should not succeed") }, + onFailure = { + onFailureCalled = true + assertTrue(it is Exception) + }) + + tcs.setException(Exception("Firestore error")) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onFailureCalled) + } + + // --- fetchBannerImageAsBitmap tests --- + + @Test + fun fetchBannerImageAsBitmap_emptyUrl_returnsFallbackImage() { + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("bannerImageUrl")).thenReturn("") + tcs.setResult(mockDocumentSnapshot) + + var onSuccessCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchBannerImageAsBitmap_urlContainsLocalEmulator_returnsFallbackImage() { + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("bannerImageUrl")).thenReturn("http://10.0.2.2:9199/banner.jpg") + tcs.setResult(mockDocumentSnapshot) + + var onSuccessCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchBannerImageAsBitmap_validUrl_fetchesFromStorageSuccess() { + val imageUrl = "https://firebasestorage.googleapis.com/v0/b/test/o/banner.jpg" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("bannerImageUrl")).thenReturn(imageUrl) + tcs.setResult(mockDocumentSnapshot) + + `when`(mockStorage.getReferenceFromUrl(eq(imageUrl))).thenReturn(mockImageRef) + val testBitmap = createTestBitmap() + val baos = ByteArrayOutputStream() + testBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos) + val bytes = baos.toByteArray() + + val tcsGetBytes = TaskCompletionSource() + `when`(mockImageRef.getBytes(Long.MAX_VALUE)).thenReturn(tcsGetBytes.task) + + var onSuccessCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + tcsGetBytes.setResult(bytes) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchBannerImageAsBitmap_validUrl_fetchesFromStorageFailure() { + val imageUrl = "https://firebasestorage.googleapis.com/v0/b/test/o/banner.jpg" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("bannerImageUrl")).thenReturn(imageUrl) + tcs.setResult(mockDocumentSnapshot) + + `when`(mockStorage.getReferenceFromUrl(eq(imageUrl))).thenReturn(mockImageRef) + val tcsGetBytes = TaskCompletionSource() + `when`(mockImageRef.getBytes(Long.MAX_VALUE)).thenReturn(tcsGetBytes.task) + + var onFailureCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { fail("Should not succeed") }, + onFailure = { + onFailureCalled = true + assertTrue(it is Exception) + }) + + tcsGetBytes.setException(Exception("Storage error")) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onFailureCalled) + } + + @Test + fun fetchBannerImageAsBitmap_firestoreFailure() { + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + + var onFailureCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { fail("Should not succeed") }, + onFailure = { + onFailureCalled = true + assertTrue(it is Exception) + }) + + tcs.setException(Exception("Firestore error")) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onFailureCalled) + } +} diff --git a/app/src/test/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestoreTest.kt b/app/src/test/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestoreTest.kt index b744e366..5693616e 100644 --- a/app/src/test/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestoreTest.kt +++ b/app/src/test/java/com/arygm/quickfix/model/profile/UserProfileRepositoryFirestoreTest.kt @@ -5,6 +5,9 @@ import android.net.Uri import android.os.Looper import androidx.test.core.app.ApplicationProvider import com.arygm.quickfix.model.locations.Location +import com.arygm.quickfix.model.profile.dataFields.AddOnService +import com.arygm.quickfix.model.profile.dataFields.IncludedService +import com.arygm.quickfix.model.profile.dataFields.Review import com.google.android.gms.tasks.OnFailureListener import com.google.android.gms.tasks.OnSuccessListener import com.google.android.gms.tasks.TaskCompletionSource @@ -22,12 +25,16 @@ import com.google.firebase.storage.FirebaseStorage import com.google.firebase.storage.StorageReference import com.google.firebase.storage.UploadTask import java.io.ByteArrayOutputStream +import java.time.LocalDate +import java.time.LocalTime import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertNull import junit.framework.TestCase.fail import org.junit.After import org.junit.Assert +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -782,4 +789,175 @@ class UserProfileRepositoryFirestoreTest { assertTrue(onFailureCalled) Assert.assertEquals(exception, exceptionReceived) } + + @Test + fun testEqualsAndHashCode() { + val location1 = Location(latitude = 10.0, longitude = 20.0, name = "Home") + val location2 = Location(latitude = 30.0, longitude = 40.0, name = "Work") + + val userProfile1 = + UserProfile( + locations = listOf(location1, location2), + announcements = listOf("announcement1", "announcement2"), + wallet = 50.0, + uid = "user123", + quickFixes = listOf("fix1", "fix2"), + savedList = listOf("saved1", "saved2")) + + val userProfile2 = + UserProfile( + locations = listOf(location1, location2), + announcements = listOf("announcement1", "announcement2"), + wallet = 50.0, + uid = "user123", + quickFixes = listOf("fix1", "fix2"), + savedList = listOf("saved1", "saved2")) + + // Another profile with different properties + val userProfile3 = + UserProfile( + locations = listOf(location1), + announcements = listOf("announcement3"), + wallet = 100.0, + uid = "user456", + quickFixes = listOf("fix3"), + savedList = listOf("saved3")) + + // Test equals + assertTrue(userProfile1 == userProfile2) + assertFalse(userProfile1 == userProfile3) + + // Test hashCode + assertEquals(userProfile1.hashCode(), userProfile2.hashCode()) + assertNotEquals(userProfile1.hashCode(), userProfile3.hashCode()) + } + + @Test + fun testCopy() { + val location1 = Location(latitude = 10.0, longitude = 20.0, name = "Home") + val location2 = Location(latitude = 30.0, longitude = 40.0, name = "Work") + + val original = + UserProfile( + locations = listOf(location1, location2), + announcements = listOf("announcement1", "announcement2"), + wallet = 50.0, + uid = "user123", + quickFixes = listOf("fix1", "fix2"), + savedList = listOf("saved1", "saved2")) + + // Copy without changing anything + val copySame = original.copy() + assertEquals(original, copySame) + + // Copy with some modifications + val modified = + original.copy(locations = listOf(location1), wallet = 100.0, savedList = listOf("newSaved")) + + assertNotEquals(original, modified) + assertEquals(listOf(location1), modified.locations) + assertEquals(100.0, modified.wallet, 0.0001) + assertEquals(listOf("newSaved"), modified.savedList) + // Check that unchanged fields remain the same + assertEquals(original.uid, modified.uid) + assertEquals(original.announcements, modified.announcements) + assertEquals(original.quickFixes, modified.quickFixes) + } + + @Test + fun testEquals() { + // Set up test data for components + val location1 = Location(latitude = 10.0, longitude = 20.0, name = "Home") + val location2 = Location(latitude = 30.0, longitude = 40.0, name = "Work") + + val includedServices = listOf(IncludedService("Service A"), IncludedService("Service B")) + + val addOnServices = listOf(AddOnService("AddOn A"), AddOnService("AddOn B")) + + val reviews = + ArrayDeque( + listOf( + Review(username = "user1", review = "Great work", rating = 4.5), + Review(username = "user2", review = "Good job", rating = 4.0))) + + val unavailabilityList = listOf(LocalDate.now().plusDays(2), LocalDate.now().plusDays(4)) + val workingHours = Pair(LocalTime.of(8, 0), LocalTime.of(16, 0)) + + // Create a base WorkerProfile + val worker1 = + WorkerProfile( + fieldOfWork = "Carpentry", + description = "Skilled carpenter", + location = location1, + quickFixes = listOf("fix1", "fix2"), + includedServices = includedServices, + addOnServices = addOnServices, + reviews = reviews, + profilePicture = "http://example.com/profile.jpg", + bannerPicture = "http://example.com/banner.jpg", + price = 100.0, + displayName = "John Doe", + unavailability_list = unavailabilityList, + workingHours = workingHours, + uid = "worker123", + tags = listOf("Professional", "Reliable"), + rating = reviews.map { it.rating }.average()) + + // Create an identical WorkerProfile + val worker2 = + WorkerProfile( + fieldOfWork = "Carpentry", + description = "Skilled carpenter", + location = location1, + quickFixes = listOf("fix1", "fix2"), + includedServices = includedServices, + addOnServices = addOnServices, + reviews = reviews, + profilePicture = "http://example.com/profile.jpg", + bannerPicture = "http://example.com/banner.jpg", + price = 100.0, + displayName = "John Doe", + unavailability_list = unavailabilityList, + workingHours = workingHours, + uid = "worker123", + tags = listOf("Professional", "Reliable"), + rating = reviews.map { it.rating }.average()) + + // A WorkerProfile with a different field to ensure not equal + val worker3 = + WorkerProfile( + fieldOfWork = "Plumbing", // changed fieldOfWork + description = "Skilled carpenter", + location = location1, + quickFixes = listOf("fix1", "fix2"), + includedServices = includedServices, + addOnServices = addOnServices, + reviews = reviews, + profilePicture = "http://example.com/profile.jpg", + bannerPicture = "http://example.com/banner.jpg", + price = 100.0, + displayName = "John Doe", + unavailability_list = unavailabilityList, + workingHours = workingHours, + uid = "worker123", + tags = listOf("Professional", "Reliable"), + rating = reviews.map { it.rating }.average()) + + // Tests + assertTrue("worker1 should be equal to worker2", worker1 == worker2) + assertEquals( + "worker1 and worker2 should have the same hashCode", worker1.hashCode(), worker2.hashCode()) + + assertFalse( + "worker1 should not be equal to worker3 because fieldOfWork differs", worker1 == worker3) + + // Check self equality + assertTrue("worker1 should be equal to itself", worker1 == worker1) + + // Check null + assertFalse("worker1 should not be equal to null", worker1 == null) + + // Check different type + assertFalse("worker1 should not be equal to a different type", worker1.equals("Some String")) + } } diff --git a/app/src/test/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestoreImageFetchTest.kt b/app/src/test/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestoreImageFetchTest.kt new file mode 100644 index 00000000..951d4421 --- /dev/null +++ b/app/src/test/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestoreImageFetchTest.kt @@ -0,0 +1,339 @@ +package com.arygm.quickfix.model.profile + +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.storage.FirebaseStorage +import com.google.firebase.storage.StorageReference +import java.io.ByteArrayOutputStream +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.eq +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class WorkerProfileRepositoryFirestoreImageFetchTest { + + @Mock private lateinit var mockFirestore: FirebaseFirestore + @Mock private lateinit var mockDocumentReference: DocumentReference + @Mock private lateinit var mockCollectionReference: CollectionReference + @Mock private lateinit var mockDocumentSnapshot: DocumentSnapshot + @Mock private lateinit var mockStorage: FirebaseStorage + @Mock private lateinit var mockStorageRef: StorageReference + @Mock private lateinit var mockImageRef: StorageReference + + private lateinit var mockFirebaseAuth: FirebaseAuth + private lateinit var firebaseAuthMockedStatic: MockedStatic + private lateinit var repository: WorkerProfileRepositoryFirestore + + private val accountId = "testAccountId" + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + if (FirebaseApp.getApps(ApplicationProvider.getApplicationContext()).isEmpty()) { + FirebaseApp.initializeApp(ApplicationProvider.getApplicationContext()) + } + + firebaseAuthMockedStatic = Mockito.mockStatic(FirebaseAuth::class.java) + mockFirebaseAuth = Mockito.mock(FirebaseAuth::class.java) + firebaseAuthMockedStatic + .`when` { FirebaseAuth.getInstance() } + .thenReturn(mockFirebaseAuth) + + `when`(mockFirestore.collection(anyString())).thenReturn(mockCollectionReference) + `when`(mockCollectionReference.document(anyString())).thenReturn(mockDocumentReference) + + `when`(mockStorage.reference).thenReturn(mockStorageRef) + + repository = WorkerProfileRepositoryFirestore(mockFirestore, mockStorage) + } + + @After + fun tearDown() { + firebaseAuthMockedStatic.close() + } + + // Helper method to create a bitmap as returned data + private fun createTestBitmap(): Bitmap { + val width = 10 + val height = 10 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.GREEN) + return bitmap + } + + // --- fetchProfileImageAsBitmap tests --- + + @Test + fun fetchProfileImageAsBitmap_emptyUrl_returnsFallbackImage() { + // Firestore returns empty URL + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("profileImageUrl")).thenReturn("") + tcs.setResult(mockDocumentSnapshot) + + var onSuccessCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchProfileImageAsBitmap_urlContainsLocalEmulator_returnsFallbackImage() { + // Firestore returns a URL containing "10.0.2.2:9199" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("profileImageUrl")) + .thenReturn("http://10.0.2.2:9199/someimage.jpg") + tcs.setResult(mockDocumentSnapshot) + + var onSuccessCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchProfileImageAsBitmap_validUrl_fetchesFromStorageSuccess() { + // Firestore returns a valid URL + val imageUrl = "https://firebasestorage.googleapis.com/v0/b/test/o/someimage.jpg" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("profileImageUrl")).thenReturn(imageUrl) + tcs.setResult(mockDocumentSnapshot) + + // Mock storage fetch + `when`(mockStorage.getReferenceFromUrl(eq(imageUrl))).thenReturn(mockImageRef) + val testBitmap = createTestBitmap() + val baos = ByteArrayOutputStream() + testBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos) + val bytes = baos.toByteArray() + + val tcsGetBytes = TaskCompletionSource() + `when`(mockImageRef.getBytes(Long.MAX_VALUE)).thenReturn(tcsGetBytes.task) + + var onSuccessCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + // Check if bitmap matches what we "returned" + // We won't decode it again here, but we can at least assert non-null + }, + onFailure = { fail("Should not fail") }) + + tcsGetBytes.setResult(bytes) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchProfileImageAsBitmap_validUrl_fetchesFromStorageFailure() { + // Firestore returns a valid URL + val imageUrl = "https://firebasestorage.googleapis.com/v0/b/test/o/someimage.jpg" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("profileImageUrl")).thenReturn(imageUrl) + tcs.setResult(mockDocumentSnapshot) + + // Mock storage fetch failure + `when`(mockStorage.getReferenceFromUrl(eq(imageUrl))).thenReturn(mockImageRef) + val tcsGetBytes = TaskCompletionSource() + `when`(mockImageRef.getBytes(Long.MAX_VALUE)).thenReturn(tcsGetBytes.task) + + var onFailureCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { fail("Should not succeed") }, + onFailure = { + onFailureCalled = true + assertTrue(it is Exception) + }) + + tcsGetBytes.setException(Exception("Storage error")) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onFailureCalled) + } + + @Test + fun fetchProfileImageAsBitmap_firestoreFailure() { + // Firestore get fails + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + + var onFailureCalled = false + repository.fetchProfileImageAsBitmap( + accountId, + onSuccess = { fail("Should not succeed") }, + onFailure = { + onFailureCalled = true + assertTrue(it is Exception) + }) + + tcs.setException(Exception("Firestore error")) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onFailureCalled) + } + + // --- fetchBannerImageAsBitmap tests --- + + @Test + fun fetchBannerImageAsBitmap_emptyUrl_returnsFallbackImage() { + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("bannerImageUrl")).thenReturn("") + tcs.setResult(mockDocumentSnapshot) + + var onSuccessCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchBannerImageAsBitmap_urlContainsLocalEmulator_returnsFallbackImage() { + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("bannerImageUrl")).thenReturn("http://10.0.2.2:9199/banner.jpg") + tcs.setResult(mockDocumentSnapshot) + + var onSuccessCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchBannerImageAsBitmap_validUrl_fetchesFromStorageSuccess() { + val imageUrl = "https://firebasestorage.googleapis.com/v0/b/test/o/banner.jpg" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("bannerImageUrl")).thenReturn(imageUrl) + tcs.setResult(mockDocumentSnapshot) + + `when`(mockStorage.getReferenceFromUrl(eq(imageUrl))).thenReturn(mockImageRef) + val testBitmap = createTestBitmap() + val baos = ByteArrayOutputStream() + testBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos) + val bytes = baos.toByteArray() + + val tcsGetBytes = TaskCompletionSource() + `when`(mockImageRef.getBytes(Long.MAX_VALUE)).thenReturn(tcsGetBytes.task) + + var onSuccessCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { bitmap -> + onSuccessCalled = true + assertNotNull(bitmap) + }, + onFailure = { fail("Should not fail") }) + + tcsGetBytes.setResult(bytes) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onSuccessCalled) + } + + @Test + fun fetchBannerImageAsBitmap_validUrl_fetchesFromStorageFailure() { + val imageUrl = "https://firebasestorage.googleapis.com/v0/b/test/o/banner.jpg" + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + `when`(mockDocumentSnapshot.exists()).thenReturn(true) + `when`(mockDocumentSnapshot.get("bannerImageUrl")).thenReturn(imageUrl) + tcs.setResult(mockDocumentSnapshot) + + `when`(mockStorage.getReferenceFromUrl(eq(imageUrl))).thenReturn(mockImageRef) + val tcsGetBytes = TaskCompletionSource() + `when`(mockImageRef.getBytes(Long.MAX_VALUE)).thenReturn(tcsGetBytes.task) + + var onFailureCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { fail("Should not succeed") }, + onFailure = { + onFailureCalled = true + assertTrue(it is Exception) + }) + + tcsGetBytes.setException(Exception("Storage error")) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onFailureCalled) + } + + @Test + fun fetchBannerImageAsBitmap_firestoreFailure() { + val tcs = TaskCompletionSource() + `when`(mockDocumentReference.get()).thenReturn(tcs.task) + + var onFailureCalled = false + repository.fetchBannerImageAsBitmap( + accountId, + onSuccess = { fail("Should not succeed") }, + onFailure = { + onFailureCalled = true + assertTrue(it is Exception) + }) + + tcs.setException(Exception("Firestore error")) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(onFailureCalled) + } +} diff --git a/app/src/test/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestoreTest.kt b/app/src/test/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestoreTest.kt index 3be4c724..61755bfd 100644 --- a/app/src/test/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestoreTest.kt +++ b/app/src/test/java/com/arygm/quickfix/model/profile/WorkerProfileRepositoryFirestoreTest.kt @@ -19,7 +19,6 @@ import com.google.firebase.firestore.CollectionReference import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore -import com.google.firebase.firestore.Query import com.google.firebase.firestore.QuerySnapshot import com.google.firebase.storage.FirebaseStorage import com.google.firebase.storage.StorageReference @@ -31,7 +30,6 @@ import junit.framework.TestCase.fail import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -41,9 +39,7 @@ import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock import org.mockito.MockedStatic import org.mockito.Mockito.any -import org.mockito.Mockito.anyDouble import org.mockito.Mockito.doNothing -import org.mockito.Mockito.eq import org.mockito.Mockito.mock import org.mockito.Mockito.mockStatic import org.mockito.Mockito.verify @@ -77,6 +73,9 @@ class WorkerProfileRepositoryFirestoreTest { @Mock private lateinit var storageRef2: StorageReference @Mock private lateinit var workerProfileFolderRef: StorageReference + private lateinit var mockImageRef: StorageReference + private lateinit var repository: WorkerProfileRepositoryFirestore + private lateinit var profileRepositoryFirestore: WorkerProfileRepositoryFirestore private val profile = @@ -136,11 +135,15 @@ class WorkerProfileRepositoryFirestoreTest { firebaseAuthMockedStatic = mockStatic(FirebaseAuth::class.java) mockFirebaseAuth = mock(FirebaseAuth::class.java) mockStorage = mock(FirebaseStorage::class.java) + mockFirestore = mock(FirebaseFirestore::class.java) // Mock FirebaseAuth.getInstance() to return the mockFirebaseAuth firebaseAuthMockedStatic .`when` { FirebaseAuth.getInstance() } .thenReturn(mockFirebaseAuth) + mockCollectionReference = mock(CollectionReference::class.java) + mockDocumentReference = mock(DocumentReference::class.java) + whenever(mockStorage.reference).thenReturn(storageRef) whenever(storageRef.child(anyString())).thenReturn(storageRef1) whenever(storageRef1.child(anyString())).thenReturn(storageRef2) @@ -151,6 +154,8 @@ class WorkerProfileRepositoryFirestoreTest { `when`(mockFirestore.collection(any())).thenReturn(mockCollectionReference) `when`(mockCollectionReference.document(any())).thenReturn(mockDocumentReference) `when`(mockCollectionReference.document()).thenReturn(mockDocumentReference) + + mockImageRef = mock(StorageReference::class.java) } @After @@ -349,80 +354,6 @@ class WorkerProfileRepositoryFirestoreTest { // ----- getProfileById Tests ----- - @Test - fun getProfileById_whenDocumentExists_callsOnSuccessWithWorkerProfile() { - val uid = "1" - - val taskCompletionSource = TaskCompletionSource() - `when`(mockDocumentReference.get()).thenReturn(taskCompletionSource.task) - `when`(mockDocumentSnapshot.exists()).thenReturn(true) - - // Mocking the data returned from Firestore - `when`(mockDocumentSnapshot.id).thenReturn(profile.uid) - `when`(mockDocumentSnapshot.getString("rating")).thenReturn(profile.rating.toString()) - `when`(mockDocumentSnapshot.get("reviews")).thenReturn(profile.reviews) - `when`(mockDocumentSnapshot.getString("description")).thenReturn(profile.description) - `when`(mockDocumentSnapshot.getString("fieldOfWork")).thenReturn(profile.fieldOfWork) - `when`(mockDocumentSnapshot.getDouble("price")).thenReturn(profile.price) - `when`(mockDocumentSnapshot.get("location")) - .thenReturn( - mapOf( - "latitude" to profile.location!!.latitude, - "longitude" to profile.location!!.longitude, - "name" to profile.location!!.name)) - `when`(mockDocumentSnapshot.getString("displayName")).thenReturn(profile.displayName) - `when`(mockDocumentSnapshot.get("includedServices")) - .thenReturn( - listOf( - mapOf("name" to profile.includedServices[0].name), - mapOf("name" to profile.includedServices[0].name))) - `when`(mockDocumentSnapshot.get("addOnServices")) - .thenReturn( - listOf( - mapOf("name" to profile.addOnServices[0].name), - mapOf("name" to profile.addOnServices[0].name))) - `when`(mockDocumentSnapshot.getString("bannerImageUrl")).thenReturn(profile.bannerPicture) - `when`(mockDocumentSnapshot.getString("profileImageUrl")).thenReturn(profile.profilePicture) - `when`(mockDocumentSnapshot.get("workingHours")) - .thenReturn( - mapOf( - "start" to profile.workingHours.first.toString(), - "end" to profile.workingHours.second.toString())) - `when`(mockDocumentSnapshot.get("unavailability_list")) - .thenReturn( - listOf( - profile.unavailability_list[0].toString(), - profile.unavailability_list[1].toString())) - `when`(mockDocumentSnapshot.get("tags")).thenReturn(profile.tags) - `when`(mockDocumentSnapshot.get("quickFixes")).thenReturn(profile.quickFixes) - `when`(mockDocumentSnapshot.get("reviews")) - .thenReturn( - listOf( - mapOf( - "username" to profile.reviews[0].username, - "review" to profile.reviews[0].review, - "rating" to profile.reviews[0].rating), - mapOf( - "username" to profile.reviews[1].username, - "review" to profile.reviews[1].review, - "rating" to profile.reviews[1].rating))) - - var callbackCalled = false - - profileRepositoryFirestore.getProfileById( - uid = uid, - onSuccess = { foundProfile -> - callbackCalled = true - assertEquals(profile, foundProfile) - }, - onFailure = { fail("Failure callback should not be called") }) - - taskCompletionSource.setResult(mockDocumentSnapshot) - shadowOf(Looper.getMainLooper()).idle() - - assertTrue(callbackCalled) - } - @Test fun getProfileById_whenDocumentDoesNotExist_callsOnSuccessWithNull() { val uid = "nonexistent" @@ -473,139 +404,6 @@ class WorkerProfileRepositoryFirestoreTest { // ----- getProfiles Tests ----- - @Test - fun getProfiles_whenSuccess_callsOnSuccessWithWorkerProfiles() { - val taskCompletionSource = TaskCompletionSource() - `when`(mockCollectionReference.get()).thenReturn(taskCompletionSource.task) - - val document1 = mock(DocumentSnapshot::class.java) - val document2 = mock(DocumentSnapshot::class.java) - - val documents = listOf(document1, document2) - `when`(mockQuerySnapshot.documents).thenReturn(documents) - - // Mock data for first document - // Mocking the data returned from Firestore - `when`(document1.id).thenReturn(profile.uid) - `when`(document1.getString("rating")).thenReturn(profile.rating.toString()) - `when`(document1.get("reviews")).thenReturn(profile.reviews) - `when`(document1.getString("description")).thenReturn(profile.description) - `when`(document1.getString("fieldOfWork")).thenReturn(profile.fieldOfWork) - `when`(document1.getDouble("price")).thenReturn(profile.price) - `when`(document1.get("location")) - .thenReturn( - mapOf( - "latitude" to profile.location!!.latitude, - "longitude" to profile.location!!.longitude, - "name" to profile.location!!.name)) - `when`(document1.getString("displayName")).thenReturn(profile.displayName) - `when`(document1.get("includedServices")) - .thenReturn( - listOf( - mapOf("name" to profile.includedServices[0].name), - mapOf("name" to profile.includedServices[0].name))) - `when`(document1.get("addOnServices")) - .thenReturn( - listOf( - mapOf("name" to profile.addOnServices[0].name), - mapOf("name" to profile.addOnServices[0].name))) - `when`(document1.getString("bannerImageUrl")).thenReturn(profile.bannerPicture) - `when`(document1.getString("profileImageUrl")).thenReturn(profile.profilePicture) - `when`(document1.get("workingHours")) - .thenReturn( - mapOf( - "start" to profile.workingHours.first.toString(), - "end" to profile.workingHours.second.toString())) - `when`(document1.get("unavailability_list")) - .thenReturn( - listOf( - profile.unavailability_list[0].toString(), - profile.unavailability_list[1].toString())) - `when`(document1.get("tags")).thenReturn(profile.tags) - `when`(document1.get("quickFixes")).thenReturn(profile.quickFixes) - `when`(document1.get("reviews")) - .thenReturn( - listOf( - mapOf( - "username" to profile.reviews[0].username, - "review" to profile.reviews[0].review, - "rating" to profile.reviews[0].rating), - mapOf( - "username" to profile.reviews[1].username, - "review" to profile.reviews[1].review, - "rating" to profile.reviews[1].rating))) - - // Mock data for second document - // Mocking the data returned from Firestore - `when`(document2.id).thenReturn(profile2.uid) - `when`(document2.getString("rating")).thenReturn(profile2.rating.toString()) - `when`(document2.get("reviews")).thenReturn(profile2.reviews) - `when`(document2.getString("description")).thenReturn(profile2.description) - `when`(document2.getString("fieldOfWork")).thenReturn(profile2.fieldOfWork) - `when`(document2.getDouble("price")).thenReturn(profile2.price) - `when`(document2.get("location")) - .thenReturn( - mapOf( - "latitude" to profile2.location!!.latitude, - "longitude" to profile2.location!!.longitude, - "name" to profile2.location!!.name)) - `when`(document2.getString("displayName")).thenReturn(profile2.displayName) - `when`(document2.get("includedServices")) - .thenReturn( - listOf( - mapOf("name" to profile2.includedServices[0].name), - mapOf("name" to profile2.includedServices[0].name))) - `when`(document2.get("addOnServices")) - .thenReturn( - listOf( - mapOf("name" to profile2.addOnServices[0].name), - mapOf("name" to profile2.addOnServices[0].name))) - `when`(document2.getString("bannerImageUrl")).thenReturn(profile2.bannerPicture) - `when`(document2.getString("profileImageUrl")).thenReturn(profile2.profilePicture) - `when`(document2.get("workingHours")) - .thenReturn( - mapOf( - "start" to profile2.workingHours.first.toString(), - "end" to profile2.workingHours.second.toString())) - `when`(document2.get("unavailability_list")) - .thenReturn( - listOf( - profile2.unavailability_list[0].toString(), - profile2.unavailability_list[1].toString())) - `when`(document2.get("tags")).thenReturn(profile2.tags) - `when`(document2.get("quickFixes")).thenReturn(profile2.quickFixes) - `when`(document2.get("reviews")) - .thenReturn( - listOf( - mapOf( - "username" to profile2.reviews[0].username, - "review" to profile2.reviews[0].review, - "rating" to profile2.reviews[0].rating), - mapOf( - "username" to profile2.reviews[1].username, - "review" to profile2.reviews[1].review, - "rating" to profile2.reviews[1].rating))) - - var callbackCalled = false - var returnedProfiles: List? = null - - profileRepositoryFirestore.getProfiles( - onSuccess = { profiles -> - callbackCalled = true - returnedProfiles = profiles - }, - onFailure = { fail("Failure callback should not be called") }) - - taskCompletionSource.setResult(mockQuerySnapshot) - shadowOf(Looper.getMainLooper()).idle() - - assertTrue(callbackCalled) - assertNotNull(returnedProfiles) - assertEquals(2, returnedProfiles!!.size) - assertEquals(profile, returnedProfiles!![0]) - assertEquals(profile2, returnedProfiles!![1]) - } - @Test fun getProfiles_whenFailure_callsOnFailure() { val taskCompletionSource = TaskCompletionSource() @@ -632,67 +430,6 @@ class WorkerProfileRepositoryFirestoreTest { // ----- documentToWorker Tests ----- - @Test - fun documentToWorker_whenAllFieldsArePresent_returnsWorkerProfile() { - // Arrange - val document = mock(DocumentSnapshot::class.java) - `when`(document.id).thenReturn(profile.uid) - `when`(document.getString("rating")).thenReturn(profile.rating.toString()) - `when`(document.get("reviews")).thenReturn(profile.reviews) - `when`(document.getString("description")).thenReturn(profile.description) - `when`(document.getString("fieldOfWork")).thenReturn(profile.fieldOfWork) - `when`(document.getDouble("price")).thenReturn(profile.price) - `when`(document.get("location")) - .thenReturn( - mapOf( - "latitude" to profile.location!!.latitude, - "longitude" to profile.location!!.longitude, - "name" to profile.location!!.name)) - `when`(document.getString("displayName")).thenReturn(profile.displayName) - `when`(document.get("includedServices")) - .thenReturn( - listOf( - mapOf("name" to profile.includedServices[0].name), - mapOf("name" to profile.includedServices[0].name))) - `when`(document.get("addOnServices")) - .thenReturn( - listOf( - mapOf("name" to profile.addOnServices[0].name), - mapOf("name" to profile.addOnServices[0].name))) - `when`(document.getString("bannerImageUrl")).thenReturn(profile.bannerPicture) - `when`(document.getString("profileImageUrl")).thenReturn(profile.profilePicture) - `when`(document.get("workingHours")) - .thenReturn( - mapOf( - "start" to profile.workingHours.first.toString(), - "end" to profile.workingHours.second.toString())) - `when`(document.get("unavailability_list")) - .thenReturn( - listOf( - profile.unavailability_list[0].toString(), - profile.unavailability_list[1].toString())) - `when`(document.get("tags")).thenReturn(profile.tags) - `when`(document.get("quickFixes")).thenReturn(profile.quickFixes) - `when`(document.get("reviews")) - .thenReturn( - listOf( - mapOf( - "username" to profile.reviews[0].username, - "review" to profile.reviews[0].review, - "rating" to profile.reviews[0].rating), - mapOf( - "username" to profile.reviews[1].username, - "review" to profile.reviews[1].review, - "rating" to profile.reviews[1].rating))) - - // Act - val result = invokeDocumentToWorker(document) - - // Assert - assertNotNull(result) - assertEquals(profile, result) - } - @Test fun documentToWorker_whenEssentialFieldsAreMissing_returnsNull() { // Arrange @@ -839,442 +576,6 @@ class WorkerProfileRepositoryFirestoreTest { // ----- filterWorkers Tests ----- - @Test - fun filterWorkers_withFieldOfWork_callsOnSuccess() { - // Create mocks for the chained methods - val mockQueryAfterFieldOfWork = mock(Query::class.java) - - // Mock method chaining - `when`(mockCollectionReference.whereEqualTo(eq("fieldOfWork"), eq("Plumber"))) - .thenReturn(mockQueryAfterFieldOfWork) - - val taskCompletionSource = TaskCompletionSource() - `when`(mockQueryAfterFieldOfWork.get()).thenReturn(taskCompletionSource.task) - - // Mock query result to return one worker profile - // Mock query result to return one worker profile - `when`(mockQuerySnapshot.documents).thenReturn(listOf(mockDocumentSnapshot)) - - // Mock data for first document - `when`(mockDocumentSnapshot.id).thenReturn(profile.uid) - `when`(mockDocumentSnapshot.getString("rating")).thenReturn(profile.rating.toString()) - `when`(mockDocumentSnapshot.get("reviews")).thenReturn(profile.reviews) - `when`(mockDocumentSnapshot.getString("description")).thenReturn(profile.description) - `when`(mockDocumentSnapshot.getString("fieldOfWork")).thenReturn(profile.fieldOfWork) - `when`(mockDocumentSnapshot.getDouble("price")).thenReturn(profile.price) - `when`(mockDocumentSnapshot.get("location")) - .thenReturn( - mapOf( - "latitude" to profile.location!!.latitude, - "longitude" to profile.location!!.longitude, - "name" to profile.location!!.name)) - `when`(mockDocumentSnapshot.getString("displayName")).thenReturn(profile.displayName) - `when`(mockDocumentSnapshot.get("includedServices")) - .thenReturn( - listOf( - mapOf("name" to profile.includedServices[0].name), - mapOf("name" to profile.includedServices[0].name))) - `when`(mockDocumentSnapshot.get("addOnServices")) - .thenReturn( - listOf( - mapOf("name" to profile.addOnServices[0].name), - mapOf("name" to profile.addOnServices[0].name))) - `when`(mockDocumentSnapshot.getString("bannerImageUrl")).thenReturn(profile.bannerPicture) - `when`(mockDocumentSnapshot.getString("profileImageUrl")).thenReturn(profile.profilePicture) - `when`(mockDocumentSnapshot.get("workingHours")) - .thenReturn( - mapOf( - "start" to profile.workingHours.first.toString(), - "end" to profile.workingHours.second.toString())) - `when`(mockDocumentSnapshot.get("unavailability_list")) - .thenReturn( - listOf( - profile.unavailability_list[0].toString(), - profile.unavailability_list[1].toString())) - `when`(mockDocumentSnapshot.get("tags")).thenReturn(profile.tags) - `when`(mockDocumentSnapshot.get("quickFixes")).thenReturn(profile.quickFixes) - `when`(mockDocumentSnapshot.get("reviews")) - .thenReturn( - listOf( - mapOf( - "username" to profile.reviews[0].username, - "review" to profile.reviews[0].review, - "rating" to profile.reviews[0].rating), - mapOf( - "username" to profile.reviews[1].username, - "review" to profile.reviews[1].review, - "rating" to profile.reviews[1].rating))) - - var callbackCalled = false - var returnedProfiles: List? = null - - profileRepositoryFirestore.filterWorkers( - rating = null, - reviews = null, - fieldOfWork = "Plumber", - price = null, - location = null, - radiusInKm = null, - onSuccess = { profiles -> - callbackCalled = true - returnedProfiles = profiles - }, - onFailure = { fail("Failure callback should not be called") }) - - // Simulate Firestore success - taskCompletionSource.setResult(mockQuerySnapshot) - shadowOf(Looper.getMainLooper()).idle() - - // Assert that the success callback was called and the profile matches - assertTrue(callbackCalled) - assertNotNull(returnedProfiles) - assertEquals(1, returnedProfiles!!.size) - assertEquals(profile, returnedProfiles!![0]) - } - - @Test - fun filterWorkers_withHourlyRateThreshold_callsOnSuccess() { - // Create mocks for the chained methods - val mockQueryAfterHourlyRate = mock(Query::class.java) - - // Mock method chaining - `when`(mockCollectionReference.whereLessThan(eq("price"), eq(30.0))) - .thenReturn(mockQueryAfterHourlyRate) - - val taskCompletionSource = TaskCompletionSource() - `when`(mockQueryAfterHourlyRate.get()).thenReturn(taskCompletionSource.task) - - // Mock query result to return one worker profile - // Mock query result to return one worker profile - `when`(mockQuerySnapshot.documents).thenReturn(listOf(mockDocumentSnapshot)) - - // Mock data for first document - `when`(mockDocumentSnapshot.id).thenReturn(profile.uid) - `when`(mockDocumentSnapshot.getString("rating")).thenReturn(profile.rating.toString()) - `when`(mockDocumentSnapshot.get("reviews")).thenReturn(profile.reviews) - `when`(mockDocumentSnapshot.getString("description")).thenReturn(profile.description) - `when`(mockDocumentSnapshot.getString("fieldOfWork")).thenReturn(profile.fieldOfWork) - `when`(mockDocumentSnapshot.getDouble("price")).thenReturn(profile.price) - `when`(mockDocumentSnapshot.get("location")) - .thenReturn( - mapOf( - "latitude" to profile.location!!.latitude, - "longitude" to profile.location!!.longitude, - "name" to profile.location!!.name)) - `when`(mockDocumentSnapshot.getString("displayName")).thenReturn(profile.displayName) - `when`(mockDocumentSnapshot.get("includedServices")) - .thenReturn( - listOf( - mapOf("name" to profile.includedServices[0].name), - mapOf("name" to profile.includedServices[0].name))) - `when`(mockDocumentSnapshot.get("addOnServices")) - .thenReturn( - listOf( - mapOf("name" to profile.addOnServices[0].name), - mapOf("name" to profile.addOnServices[0].name))) - `when`(mockDocumentSnapshot.getString("bannerImageUrl")).thenReturn(profile.bannerPicture) - `when`(mockDocumentSnapshot.getString("profileImageUrl")).thenReturn(profile.profilePicture) - `when`(mockDocumentSnapshot.get("workingHours")) - .thenReturn( - mapOf( - "start" to profile.workingHours.first.toString(), - "end" to profile.workingHours.second.toString())) - `when`(mockDocumentSnapshot.get("unavailability_list")) - .thenReturn( - listOf( - profile.unavailability_list[0].toString(), - profile.unavailability_list[1].toString())) - `when`(mockDocumentSnapshot.get("tags")).thenReturn(profile.tags) - `when`(mockDocumentSnapshot.get("quickFixes")).thenReturn(profile.quickFixes) - `when`(mockDocumentSnapshot.get("reviews")) - .thenReturn( - listOf( - mapOf( - "username" to profile.reviews[0].username, - "review" to profile.reviews[0].review, - "rating" to profile.reviews[0].rating), - mapOf( - "username" to profile.reviews[1].username, - "review" to profile.reviews[1].review, - "rating" to profile.reviews[1].rating))) - - var callbackCalled = false - var returnedProfiles: List? = null - - profileRepositoryFirestore.filterWorkers( - rating = null, - reviews = null, - price = 30.0, - fieldOfWork = null, - location = null, - radiusInKm = null, - onSuccess = { profiles -> - callbackCalled = true - returnedProfiles = profiles - }, - onFailure = { fail("Failure callback should not be called") }) - - // Simulate Firestore success - taskCompletionSource.setResult(mockQuerySnapshot) - shadowOf(Looper.getMainLooper()).idle() - - // Assert that the success callback was called and the profile matches - assertTrue(callbackCalled) - assertNotNull(returnedProfiles) - assertEquals(1, returnedProfiles!!.size) - assertEquals(profile, returnedProfiles!![0]) - } - - @Test - fun filterWorkers_withFieldOfWorkAndHourlyRateThreshold_callsOnSuccess() { - // Create mocks for the chained methods - val mockQueryAfterFieldOfWork = mock(Query::class.java) - val mockQueryAfterHourlyRate = mock(Query::class.java) - - // Mock method chaining - `when`(mockCollectionReference.whereEqualTo(eq("fieldOfWork"), eq("Plumber"))) - .thenReturn(mockQueryAfterFieldOfWork) - `when`(mockQueryAfterFieldOfWork.whereLessThan(eq("price"), eq(30.0))) - .thenReturn(mockQueryAfterHourlyRate) - - val taskCompletionSource = TaskCompletionSource() - `when`(mockQueryAfterHourlyRate.get()).thenReturn(taskCompletionSource.task) - - // Mock query result to return one worker profile - `when`(mockQuerySnapshot.documents).thenReturn(listOf(mockDocumentSnapshot)) - - // Mock data for first document - `when`(mockDocumentSnapshot.id).thenReturn(profile.uid) - `when`(mockDocumentSnapshot.getString("rating")).thenReturn(profile.rating.toString()) - `when`(mockDocumentSnapshot.get("reviews")).thenReturn(profile.reviews) - `when`(mockDocumentSnapshot.getString("description")).thenReturn(profile.description) - `when`(mockDocumentSnapshot.getString("fieldOfWork")).thenReturn(profile.fieldOfWork) - `when`(mockDocumentSnapshot.getDouble("price")).thenReturn(profile.price) - `when`(mockDocumentSnapshot.get("location")) - .thenReturn( - mapOf( - "latitude" to profile.location!!.latitude, - "longitude" to profile.location!!.longitude, - "name" to profile.location!!.name)) - `when`(mockDocumentSnapshot.getString("displayName")).thenReturn(profile.displayName) - `when`(mockDocumentSnapshot.get("includedServices")) - .thenReturn( - listOf( - mapOf("name" to profile.includedServices[0].name), - mapOf("name" to profile.includedServices[0].name))) - `when`(mockDocumentSnapshot.get("addOnServices")) - .thenReturn( - listOf( - mapOf("name" to profile.addOnServices[0].name), - mapOf("name" to profile.addOnServices[0].name))) - `when`(mockDocumentSnapshot.getString("bannerImageUrl")).thenReturn(profile.bannerPicture) - `when`(mockDocumentSnapshot.getString("profileImageUrl")).thenReturn(profile.profilePicture) - `when`(mockDocumentSnapshot.get("workingHours")) - .thenReturn( - mapOf( - "start" to profile.workingHours.first.toString(), - "end" to profile.workingHours.second.toString())) - `when`(mockDocumentSnapshot.get("unavailability_list")) - .thenReturn( - listOf( - profile.unavailability_list[0].toString(), - profile.unavailability_list[1].toString())) - `when`(mockDocumentSnapshot.get("tags")).thenReturn(profile.tags) - `when`(mockDocumentSnapshot.get("quickFixes")).thenReturn(profile.quickFixes) - `when`(mockDocumentSnapshot.get("reviews")) - .thenReturn( - listOf( - mapOf( - "username" to profile.reviews[0].username, - "review" to profile.reviews[0].review, - "rating" to profile.reviews[0].rating), - mapOf( - "username" to profile.reviews[1].username, - "review" to profile.reviews[1].review, - "rating" to profile.reviews[1].rating))) - - var callbackCalled = false - var returnedProfiles: List? = null - - profileRepositoryFirestore.filterWorkers( - rating = null, - reviews = null, - price = 30.0, - fieldOfWork = "Plumber", - location = null, - radiusInKm = null, - onSuccess = { profiles -> - callbackCalled = true - returnedProfiles = profiles - }, - onFailure = { fail("Failure callback should not be called") }) - - // Simulate Firestore success - taskCompletionSource.setResult(mockQuerySnapshot) - shadowOf(Looper.getMainLooper()).idle() - - // Assert that the success callback was called and the profile matches - assertTrue(callbackCalled) - assertNotNull(returnedProfiles) - assertEquals(1, returnedProfiles!!.size) - assertEquals(profile, returnedProfiles!![0]) - } - - @Test - fun filterWorkers_withLocationAndRadius_callsOnSuccess() { - // Create mocks for the chained methods - val mockQueryAfterLatitudeMin = mock(Query::class.java) - val mockQueryAfterLatitudeMax = mock(Query::class.java) - val mockQueryAfterLongitudeMin = mock(Query::class.java) - val mockQueryAfterLongitudeMax = mock(Query::class.java) - - val location = Location(latitude = 37.7749, longitude = -122.4194, name = "Home") - - // Starting from the collection reference - val query = mockCollectionReference as Query - - // Mock whereGreaterThanOrEqualTo for latitude - `when`(query.whereGreaterThanOrEqualTo(eq("location.latitude"), anyDouble())) - .thenReturn(mockQueryAfterLatitudeMin) - - // Mock whereLessThanOrEqualTo for latitude - `when`(mockQueryAfterLatitudeMin.whereLessThanOrEqualTo(eq("location.latitude"), anyDouble())) - .thenReturn(mockQueryAfterLatitudeMax) - - // Mock whereGreaterThanOrEqualTo for longitude - `when`( - mockQueryAfterLatitudeMax.whereGreaterThanOrEqualTo( - eq("location.longitude"), anyDouble())) - .thenReturn(mockQueryAfterLongitudeMin) - - // Mock whereLessThanOrEqualTo for longitude - `when`(mockQueryAfterLongitudeMin.whereLessThanOrEqualTo(eq("location.longitude"), anyDouble())) - .thenReturn(mockQueryAfterLongitudeMax) - - val taskCompletionSource = TaskCompletionSource() - `when`(mockQueryAfterLongitudeMax.get()).thenReturn(taskCompletionSource.task) - - // Mock query result to return one worker profile - `when`(mockQuerySnapshot.documents).thenReturn(listOf(mockDocumentSnapshot)) - - // Mock data for first document - `when`(mockDocumentSnapshot.id).thenReturn(profile.uid) - `when`(mockDocumentSnapshot.getString("rating")).thenReturn(profile.rating.toString()) - `when`(mockDocumentSnapshot.get("reviews")).thenReturn(profile.reviews) - `when`(mockDocumentSnapshot.getString("description")).thenReturn(profile.description) - `when`(mockDocumentSnapshot.getString("fieldOfWork")).thenReturn(profile.fieldOfWork) - `when`(mockDocumentSnapshot.getDouble("price")).thenReturn(profile.price) - `when`(mockDocumentSnapshot.get("location")) - .thenReturn( - mapOf( - "latitude" to profile.location!!.latitude, - "longitude" to profile.location!!.longitude, - "name" to profile.location!!.name)) - `when`(mockDocumentSnapshot.getString("displayName")).thenReturn(profile.displayName) - `when`(mockDocumentSnapshot.get("includedServices")) - .thenReturn( - listOf( - mapOf("name" to profile.includedServices[0].name), - mapOf("name" to profile.includedServices[0].name))) - `when`(mockDocumentSnapshot.get("addOnServices")) - .thenReturn( - listOf( - mapOf("name" to profile.addOnServices[0].name), - mapOf("name" to profile.addOnServices[0].name))) - `when`(mockDocumentSnapshot.getString("bannerImageUrl")).thenReturn(profile.bannerPicture) - `when`(mockDocumentSnapshot.getString("profileImageUrl")).thenReturn(profile.profilePicture) - `when`(mockDocumentSnapshot.get("workingHours")) - .thenReturn( - mapOf( - "start" to profile.workingHours.first.toString(), - "end" to profile.workingHours.second.toString())) - `when`(mockDocumentSnapshot.get("unavailability_list")) - .thenReturn( - listOf( - profile.unavailability_list[0].toString(), - profile.unavailability_list[1].toString())) - `when`(mockDocumentSnapshot.get("tags")).thenReturn(profile.tags) - `when`(mockDocumentSnapshot.get("quickFixes")).thenReturn(profile.quickFixes) - `when`(mockDocumentSnapshot.get("reviews")) - .thenReturn( - listOf( - mapOf( - "username" to profile.reviews[0].username, - "review" to profile.reviews[0].review, - "rating" to profile.reviews[0].rating), - mapOf( - "username" to profile.reviews[1].username, - "review" to profile.reviews[1].review, - "rating" to profile.reviews[1].rating))) - - var callbackCalled = false - var returnedProfiles: List? = null - - profileRepositoryFirestore.filterWorkers( - rating = null, - reviews = null, - price = null, - fieldOfWork = null, - location = location, - radiusInKm = 50.0, - onSuccess = { profiles -> - callbackCalled = true - returnedProfiles = profiles - }, - onFailure = { fail("Failure callback should not be called") }) - - // Simulate Firestore success - taskCompletionSource.setResult(mockQuerySnapshot) - shadowOf(Looper.getMainLooper()).idle() - - // Assert that the success callback was called and the profile matches - assertTrue(callbackCalled) - assertNotNull(returnedProfiles) - assertEquals(1, returnedProfiles!!.size) - assertEquals(profile, returnedProfiles!![0]) - } - - @Test - fun filterWorkers_onFailure_callsOnFailure() { - // Create mocks for the chained methods - val mockQueryAfterFieldOfWork = mock(Query::class.java) - val mockQueryAfterHourlyRate = mock(Query::class.java) - - // Mock method chaining - `when`(mockCollectionReference.whereEqualTo(eq("fieldOfWork"), eq("Plumber"))) - .thenReturn(mockQueryAfterFieldOfWork) - `when`(mockQueryAfterFieldOfWork.whereLessThan(eq("price"), eq(30.0))) - .thenReturn(mockQueryAfterHourlyRate) - - val taskCompletionSource = TaskCompletionSource() - `when`(mockQueryAfterHourlyRate.get()).thenReturn(taskCompletionSource.task) - - val exception = Exception("Test exception") - var callbackCalled = false - var returnedException: Exception? = null - - profileRepositoryFirestore.filterWorkers( - rating = null, - reviews = null, - price = 30.0, - fieldOfWork = "Plumber", - location = null, - radiusInKm = null, - onSuccess = { fail("Success callback should not be called") }, - onFailure = { e -> - callbackCalled = true - returnedException = e - }) - - // Simulate Firestore failure - taskCompletionSource.setException(exception) - shadowOf(Looper.getMainLooper()).idle() - - // Assert that the failure callback was called and the exception matches - assertTrue(callbackCalled) - assertEquals(exception, returnedException) - } - @Test fun uploadWorkerProfileImages_success() { val accountId = "workerProfileId" diff --git a/app/src/test/java/com/arygm/quickfix/model/search/AnnouncementRepositoryFirestoreTest.kt b/app/src/test/java/com/arygm/quickfix/model/search/AnnouncementRepositoryFirestoreTest.kt index b09453eb..4359e8a6 100644 --- a/app/src/test/java/com/arygm/quickfix/model/search/AnnouncementRepositoryFirestoreTest.kt +++ b/app/src/test/java/com/arygm/quickfix/model/search/AnnouncementRepositoryFirestoreTest.kt @@ -16,6 +16,7 @@ import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FieldPath import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query import com.google.firebase.firestore.QuerySnapshot import com.google.firebase.storage.FirebaseStorage import com.google.firebase.storage.StorageReference @@ -50,7 +51,7 @@ class AnnouncementRepositoryFirestoreTest { @Mock private lateinit var mockCollectionReference: CollectionReference @Mock private lateinit var mockDocumentReference: DocumentReference @Mock private lateinit var mockQuerySnapshot: QuerySnapshot - @Mock private lateinit var mockDocumentSnapshot: DocumentSnapshot + @Mock private lateinit var mockQuery: Query // Mocks for FirebaseStorage @Mock private lateinit var mockStorage: FirebaseStorage @@ -81,17 +82,20 @@ class AnnouncementRepositoryFirestoreTest { FirebaseApp.initializeApp(ApplicationProvider.getApplicationContext()) } + // Mock storage + whenever(mockStorage.reference).thenReturn(storageRef) + whenever(storageRef.child(anyString())).thenReturn(announcementFolderRef) + announcementRepositoryFirestore = AnnouncementRepositoryFirestore(mockFirestore, mockStorage) + // Mock the Firestore structure whenever(mockFirestore.collection(any())).thenReturn(mockCollectionReference) whenever(mockCollectionReference.document(any())).thenReturn(mockDocumentReference) whenever(mockCollectionReference.document()).thenReturn(mockDocumentReference) + `when`(mockCollectionReference.get()).thenReturn(Tasks.forResult(mockQuerySnapshot)) - // Mock storage - whenever(mockStorage.reference).thenReturn(storageRef) - whenever(storageRef.child(anyString())).thenReturn(announcementFolderRef) - - // Initialize the repository with mocked Firestore and Storage - announcementRepositoryFirestore = AnnouncementRepositoryFirestore(mockFirestore, mockStorage) + `when`(mockCollectionReference.whereEqualTo(eq("category"), any())) + .thenReturn(mockQuery) + `when`(mockQuery.get()).thenReturn(Tasks.forResult(mockQuerySnapshot)) } @Test @@ -614,4 +618,113 @@ class AnnouncementRepositoryFirestoreTest { assertTrue(failureCalled) assertEquals(exception, exceptionReceived) } + + @Test + fun getAnnouncementsByCategory_whenNonEmpty_callsOnSuccessWithAnnouncements() { + val category = "Plumbing" + + // Set up mock documents + val mockDoc1 = mock(DocumentSnapshot::class.java) + val mockDoc2 = mock(DocumentSnapshot::class.java) + + // Mock Firestore behavior for a non-empty result + `when`(mockCollectionReference.whereEqualTo("category", category)).thenReturn(mockQuery) + `when`(mockQuery.get()).thenReturn(Tasks.forResult(mockQuerySnapshot)) + `when`(mockQuerySnapshot.documents).thenReturn(listOf(mockDoc1, mockDoc2)) + + // Mock document fields + `when`(mockDoc1.id).thenReturn("doc1") + `when`(mockDoc1.getString("userId")).thenReturn("userId1") + `when`(mockDoc1.getString("title")).thenReturn("Title 1") + `when`(mockDoc1.getString("category")).thenReturn("Plumbing") + `when`(mockDoc1.getString("description")).thenReturn("Desc 1") + `when`(mockDoc1.get("location")) + .thenReturn(mapOf("latitude" to 1.0, "longitude" to 2.0, "name" to "Location1")) + `when`(mockDoc1.get("availability")).thenReturn(emptyList>()) + `when`(mockDoc1.get("quickFixImages")).thenReturn(emptyList()) + + `when`(mockDoc2.id).thenReturn("doc2") + `when`(mockDoc2.getString("userId")).thenReturn("userId2") + `when`(mockDoc2.getString("title")).thenReturn("Title 2") + `when`(mockDoc2.getString("category")).thenReturn("Plumbing") + `when`(mockDoc2.getString("description")).thenReturn("Desc 2") + `when`(mockDoc2.get("location")) + .thenReturn(mapOf("latitude" to 3.0, "longitude" to 4.0, "name" to "Location2")) + `when`(mockDoc2.get("availability")).thenReturn(emptyList>()) + `when`(mockDoc2.get("quickFixImages")).thenReturn(emptyList()) + + var successCalled = false + var announcementsReceived: List? = null + + announcementRepositoryFirestore.getAnnouncementsByCategory( + category, + onSuccess = { + successCalled = true + announcementsReceived = it + }, + onFailure = { fail("Failure should not be called") }) + + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(successCalled) + assertNotNull(announcementsReceived) + assertEquals(2, announcementsReceived!!.size) + assertEquals("Title 1", announcementsReceived!![0].title) + assertEquals("Title 2", announcementsReceived!![1].title) + } + + @Test + fun getAnnouncementsByCategory_whenEmpty_callsOnSuccessWithEmptyList() { + val category = "Gardening" + + // Mock Firestore behavior for empty result + `when`(mockCollectionReference.whereEqualTo("category", category)).thenReturn(mockQuery) + `when`(mockQuery.get()).thenReturn(Tasks.forResult(mockQuerySnapshot)) + `when`(mockQuerySnapshot.isEmpty).thenReturn(true) + `when`(mockQuerySnapshot.documents).thenReturn(emptyList()) + + var successCalled = false + var announcementsReceived: List? = null + + announcementRepositoryFirestore.getAnnouncementsByCategory( + category, + onSuccess = { + successCalled = true + announcementsReceived = it + }, + onFailure = { fail("Failure should not be called") }) + + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(successCalled) + assertNotNull(announcementsReceived) + assertTrue(announcementsReceived!!.isEmpty()) + } + + @Test + fun getAnnouncementsByCategory_whenFailure_callsOnFailure() { + val category = "Plumbing" + val exception = Exception("Firestore error") + + // Mock Firestore query failure + `when`(mockQuery.get()).thenReturn(Tasks.forException(exception)) + + var callbackCalled = false + var receivedException: Exception? = null + + // Act + announcementRepositoryFirestore.getAnnouncementsByCategory( + category = category, + onSuccess = { fail("Success callback should not be called") }, + onFailure = { e -> + callbackCalled = true + receivedException = e + }) + + shadowOf(Looper.getMainLooper()).idle() + + // Assert + assertTrue(callbackCalled) + assertEquals(exception, receivedException) + } } diff --git a/app/src/test/java/com/arygm/quickfix/model/search/AnnouncementViewModelTest.kt b/app/src/test/java/com/arygm/quickfix/model/search/AnnouncementViewModelTest.kt index 7ff0b4df..3a413cab 100644 --- a/app/src/test/java/com/arygm/quickfix/model/search/AnnouncementViewModelTest.kt +++ b/app/src/test/java/com/arygm/quickfix/model/search/AnnouncementViewModelTest.kt @@ -6,6 +6,9 @@ import com.arygm.quickfix.model.offline.small.PreferencesRepository import com.arygm.quickfix.model.profile.Profile import com.arygm.quickfix.model.profile.ProfileRepository import com.arygm.quickfix.model.profile.UserProfile +import com.arygm.quickfix.model.profile.UserProfileRepositoryFirestore +import com.arygm.quickfix.model.profile.WorkerProfile +import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.utils.UID_KEY import com.google.firebase.Timestamp import kotlinx.coroutines.Dispatchers @@ -595,4 +598,191 @@ class AnnouncementViewModelTest { assertEquals(pairs, announcementViewModel.announcementImagesMap.value["announcement1"]) } + + @Test + fun `init with UserProfileRepositoryFirestore calls getAnnouncementsForCurrentUser`() = runTest { + // Given a UserProfileRepositoryFirestore + val userProfileRepository = mock(UserProfileRepositoryFirestore::class.java) + whenever(mockRepository.init(any())).thenAnswer { invocation -> + val onSuccess = invocation.getArgument<() -> Unit>(0) + onSuccess() + null + } + + // When we create a new ViewModel with userProfileRepository + val viewModel = + AnnouncementViewModel(mockRepository, mockPreferencesRepository, userProfileRepository) + + verify(mockRepository, times(1)).init(any()) + } + + @Test + fun `init with WorkerProfileRepositoryFirestore calls getAnnouncementsForCurrentWorker`() = + runTest { + // Given a WorkerProfileRepositoryFirestore + val workerProfileRepository = mock(WorkerProfileRepositoryFirestore::class.java) + whenever(mockRepository.init(any())).thenAnswer { invocation -> + // Simulate immediate success callback + val onSuccess = invocation.getArgument<() -> Unit>(0) + onSuccess() + null + } + + // When we create a new ViewModel with workerProfileRepository + val viewModel = + AnnouncementViewModel( + mockRepository, mockPreferencesRepository, workerProfileRepository) + + // Then getAnnouncementsForCurrentWorker should have been called + verify(mockRepository, times(1)).init(any()) + } + + @Test + fun `getAnnouncementsByCategory success updates announcements`() = runTest { + val category = "Plumbing" + val filteredAnnouncements = listOf(announcement1) + + doAnswer { invocation -> + val onSuccess = invocation.getArgument<(List) -> Unit>(1) + onSuccess(filteredAnnouncements) + null + } + .whenever(mockRepository) + .getAnnouncementsByCategory(eq(category), any(), any()) + + announcementViewModel.getAnnouncementsByCategory(category) + + assertEquals(filteredAnnouncements, announcementViewModel.announcements.value) + verify(mockRepository).getAnnouncementsByCategory(eq(category), any(), any()) + } + + @Test + fun `getAnnouncementsByCategory failure logs error and announcements remain empty`() = runTest { + val category = "Plumbing" + val exception = Exception("Category fetch failed") + + doAnswer { invocation -> + val onFailure = invocation.getArgument<(Exception) -> Unit>(2) + onFailure(exception) + null + } + .whenever(mockRepository) + .getAnnouncementsByCategory(eq(category), any(), any()) + + announcementViewModel.getAnnouncementsByCategory(category) + + // announcements should remain unchanged (empty) + assertTrue(announcementViewModel.announcements.value.isEmpty()) + } + + @Test + fun `getAnnouncementsForCurrentWorker with no userId logs error`() = runTest { + whenever(mockPreferencesRepository.getPreferenceByKey(UID_KEY)).thenReturn(flowOf(null)) + + announcementViewModel.getAnnouncementsForCurrentWorker() + + // There's no direct observable state changed here when userId is null, + // but no crash should occur. + // Just ensuring the code runs through without errors is enough. + verify(mockProfileRepository, never()).getProfileById(anyString(), any(), any()) + } + + @Test + fun `getAnnouncementsForCurrentWorker with non-worker profile logs error`() = runTest { + whenever(mockPreferencesRepository.getPreferenceByKey(UID_KEY)).thenReturn(flowOf("user123")) + + // Return a UserProfile (not a WorkerProfile) + val userProfile = UserProfile(emptyList(), emptyList(), 0.0, "user123", emptyList()) + doAnswer { invocation -> + val onSuccess = invocation.getArgument<(UserProfile) -> Unit>(1) + onSuccess(userProfile) + null + } + .whenever(mockProfileRepository) + .getProfileById(eq("user123"), any(), any()) + + announcementViewModel.getAnnouncementsForCurrentWorker() + + // No announcementsByCategory call should be made, since profile is not WorkerProfile + verify(mockRepository, never()).getAnnouncementsByCategory(anyString(), any(), any()) + } + + @Test + fun `getAnnouncementsForCurrentWorker with worker profile calls getAnnouncementsByCategory`() = + runTest { + whenever(mockPreferencesRepository.getPreferenceByKey(UID_KEY)) + .thenReturn(flowOf("worker123")) + + val workerProfile = WorkerProfile("Plumbing", "worker123") + doAnswer { invocation -> + val onSuccess = invocation.getArgument<(WorkerProfile) -> Unit>(1) + onSuccess(workerProfile) + null + } + .whenever(mockProfileRepository) + .getProfileById(eq("worker123"), any(), any()) + + // Mock the result of getAnnouncementsByCategory + doAnswer { invocation -> + val onSuccess = invocation.getArgument<(List) -> Unit>(1) + onSuccess(listOf(announcement2)) + null + } + .whenever(mockRepository) + .getAnnouncementsByCategory(eq("Plumbing"), any(), any()) + + announcementViewModel.getAnnouncementsForCurrentWorker() + + assertEquals(listOf(announcement2), announcementViewModel.announcements.value) + verify(mockRepository).getAnnouncementsByCategory(eq("Plumbing"), any(), any()) + } + + @Test + fun `getAnnouncementsForCurrentWorker profile fetch failure logs error`() = runTest { + whenever(mockPreferencesRepository.getPreferenceByKey(UID_KEY)).thenReturn(flowOf("worker123")) + val exception = Exception("Worker profile fetch failed") + + doAnswer { invocation -> + val onFailure = invocation.getArgument<(Exception) -> Unit>(2) + onFailure(exception) + null + } + .whenever(mockProfileRepository) + .getProfileById(eq("worker123"), any(), any()) + + announcementViewModel.getAnnouncementsForCurrentWorker() + + // No announcements fetched + assertTrue(announcementViewModel.announcements.value.isEmpty()) + } + + @Test + fun `filterAnnouncementsByDistance returns only announcements within maxDistance`() { + val userLocation = Location(0.0, 0.0, "Origin") + val closeAnnouncement = + announcement1.copy(location = Location(0.0, 0.01, "Close")) // ~1.11km away + val farAnnouncement = announcement2.copy(location = Location(1.0, 1.0, "Far")) // ~157 km away + + val announcements = listOf(closeAnnouncement, farAnnouncement) + + // maxDistance in km (e.g. 10 km) + val filtered = + announcementViewModel.filterAnnouncementsByDistance(announcements, userLocation, 10) + + // Only the closeAnnouncement should appear + assertEquals(1, filtered.size) + assertEquals(closeAnnouncement, filtered[0]) + } + + @Test + fun `calculateDistance returns correct distance`() { + val lat1 = 0.0 + val lon1 = 0.0 + val lat2 = 0.0 + val lon2 = 1.0 // 1 degree of longitude at the equator ~ 111 km + + val distance = announcementViewModel.calculateDistance(lat1, lon1, lat2, lon2) + // Roughly check if the distance is about 111 km + assertTrue(distance > 100.0 && distance < 120.0) + } } diff --git a/app/src/test/java/com/arygm/quickfix/model/tools/ai/QuickFixViewModelTest.kt b/app/src/test/java/com/arygm/quickfix/model/tools/ai/QuickFixViewModelTest.kt new file mode 100644 index 00000000..f28a5933 --- /dev/null +++ b/app/src/test/java/com/arygm/quickfix/model/tools/ai/QuickFixViewModelTest.kt @@ -0,0 +1,67 @@ +package com.arygm.quickfix.model.tools.ai + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.google.ai.client.generativeai.Chat +import com.google.ai.client.generativeai.GenerativeModel +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GeminiViewModelTest { + + @get:Rule val instantExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var generativeModel: GenerativeModel + private lateinit var viewModel: GeminiViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + generativeModel = mockk() + viewModel = GeminiViewModel(generativeModel) + } + + @Test + fun `sendMessage handles errors gracefully`() = runTest { + val userQuestion = "I need an electrician." + val mockChat = mockk(relaxed = true) + + every { generativeModel.startChat(any()) } returns mockChat + every { runBlocking { mockChat.sendMessage(userQuestion) } } throws Exception("API error") + + viewModel.sendMessage(userQuestion) + + advanceUntilIdle() + + assertEquals(3, viewModel.messageList.size) + assertEquals(userQuestion, viewModel.messageList[1].message) + assertEquals("Error : API error", viewModel.messageList[2].message) + + verify { runBlocking { mockChat.sendMessage(userQuestion) } } + } + + @Test + fun `clearMessages resets message list to context message`() { + viewModel.messageList.add(GeminiMessageModel("Test message", "user")) + + viewModel.clearMessages() + + assertEquals(1, viewModel.messageList.size) + assertEquals(viewModel.contextMessage, viewModel.messageList[0].message) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } +} diff --git a/end2end-data/auth_export/accounts.json b/end2end-data/auth_export/accounts.json index e91581a3..8431f26b 100644 --- a/end2end-data/auth_export/accounts.json +++ b/end2end-data/auth_export/accounts.json @@ -1 +1 @@ -{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"FO4MzGK0RxBewIK2v3vhlBIaourz","createdAt":"1730837075989","lastLoginAt":"1730837095753","displayName":"Ramy Hatimy","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocJPGftnRmQ-9s-wnGLGNfMbm5FtDc2es3qpiEXKQcxwdejJgA=s96-c","providerUserInfo":[{"providerId":"google.com","rawId":"111595754218918728280","federatedId":"111595754218918728280","displayName":"Ramy Hatimy","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocJPGftnRmQ-9s-wnGLGNfMbm5FtDc2es3qpiEXKQcxwdejJgA=s96-c","email":"hatimyramy@gmail.com"}],"validSince":"1734336600","email":"hatimyramy@gmail.com","emailVerified":true,"disabled":false},{"localId":"NEdD5q9bLZAO7Fys3q8qJVV6n490","createdAt":"1734313443169","lastLoginAt":"1734313443169","passwordHash":"fakeHash:salt=fakeSaltJBSzEuqZa6VPtcp6EbRr:password=@@Test1234@@","salt":"fakeSaltJBSzEuqZa6VPtcp6EbRr","passwordUpdatedAt":1734336600722,"providerUserInfo":[{"providerId":"password","email":"adam.aitbousselham@epfl.ch","federatedId":"adam.aitbousselham@epfl.ch","rawId":"adam.aitbousselham@epfl.ch"}],"validSince":"1734336600","email":"adam.aitbousselham@epfl.ch","emailVerified":false,"disabled":false},{"localId":"RXtch0tIoFXQ2yyVkD2NrcrENi4Z","createdAt":"1734329478817","lastLoginAt":"1734333643798","displayName":"","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltctrK8u2z91ynYNr4j688:password=@@Test1234@@","salt":"fakeSaltctrK8u2z91ynYNr4j688","passwordUpdatedAt":1734336600722,"providerUserInfo":[{"providerId":"password","email":"main.activity@test.com","federatedId":"main.activity@test.com","rawId":"main.activity@test.com","displayName":"","photoUrl":""}],"validSince":"1734336600","email":"main.activity@test.com","emailVerified":false,"disabled":false}]} \ No newline at end of file +{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"FO4MzGK0RxBewIK2v3vhlBIaourz","createdAt":"1730837075989","lastLoginAt":"1730837095753","displayName":"Ramy Hatimy","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocJPGftnRmQ-9s-wnGLGNfMbm5FtDc2es3qpiEXKQcxwdejJgA=s96-c","providerUserInfo":[{"providerId":"google.com","rawId":"111595754218918728280","federatedId":"111595754218918728280","displayName":"Ramy Hatimy","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocJPGftnRmQ-9s-wnGLGNfMbm5FtDc2es3qpiEXKQcxwdejJgA=s96-c","email":"hatimyramy@gmail.com"}],"validSince":"1734575942","email":"hatimyramy@gmail.com","emailVerified":true,"disabled":false},{"localId":"NEdD5q9bLZAO7Fys3q8qJVV6n490","createdAt":"1734313443169","lastLoginAt":"1734313443169","passwordHash":"fakeHash:salt=fakeSaltJBSzEuqZa6VPtcp6EbRr:password=@@Test1234@@","salt":"fakeSaltJBSzEuqZa6VPtcp6EbRr","passwordUpdatedAt":1734575942225,"providerUserInfo":[{"providerId":"password","email":"adam.aitbousselham@epfl.ch","federatedId":"adam.aitbousselham@epfl.ch","rawId":"adam.aitbousselham@epfl.ch"}],"validSince":"1734575942","email":"adam.aitbousselham@epfl.ch","emailVerified":false,"disabled":false},{"localId":"RXtch0tIoFXQ2yyVkD2NrcrENi4Z","createdAt":"1734329478817","lastLoginAt":"1734333643798","displayName":"","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltctrK8u2z91ynYNr4j688:password=@@Test1234@@","salt":"fakeSaltctrK8u2z91ynYNr4j688","passwordUpdatedAt":1734575942225,"providerUserInfo":[{"providerId":"password","email":"main.activity@test.com","federatedId":"main.activity@test.com","rawId":"main.activity@test.com","displayName":"","photoUrl":""}],"validSince":"1734575942","email":"main.activity@test.com","emailVerified":false,"disabled":false}]} \ No newline at end of file diff --git a/end2end-data/firebase-export-metadata.json b/end2end-data/firebase-export-metadata.json index 8039fb2a..61ed87a9 100644 --- a/end2end-data/firebase-export-metadata.json +++ b/end2end-data/firebase-export-metadata.json @@ -1,16 +1,16 @@ { - "version": "13.23.1", + "version": "13.27.0", "firestore": { "version": "1.19.8", "path": "firestore_export", "metadata_file": "firestore_export/firestore_export.overall_export_metadata" }, "auth": { - "version": "13.23.1", + "version": "13.27.0", "path": "auth_export" }, "storage": { - "version": "13.23.1", + "version": "13.27.0", "path": "storage_export" } } \ No newline at end of file diff --git a/end2end-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/end2end-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index 99a90420c3df64457781d217e5d91f2acb3cdf72..b205d106dc7a77dadee7a17f540e246d3aa5557f 100644 GIT binary patch delta 36 ncmXppnII~^uH)#cSDU(+CH^ghFobxx7=$?TOG^q$OLPqYCt?no delta 36 ncmXppnII~Esb$rN*Q>jjC3eq;Fobxx7=$?TOG^q$OLPqYEC~+3 diff --git a/end2end-data/firestore_export/all_namespaces/all_kinds/output-0 b/end2end-data/firestore_export/all_namespaces/all_kinds/output-0 index 263901c57b14acc6fc19ef4f03699e070580660c..3cb4d9040b3344172bef87211133f135848d2313 100644 GIT binary patch delta 942 zcmZva-)<5?6vkPDO=_~BD ziN1iEcN7%b#sFz5agZoNvDQ!q&eJzuNZRpPQ@ijcizdauLp|xzH`-%f(S3 z7*&CQFD?;2FeR2p;m>lV^AcqlnE}pRmQk^_rLeQ5^bc#lh@MK9coFo?LSL=B>|I7= zw2KZr&c?lsbd9> zAp^=l;SLc~h#sPG3OYf@y$N`W=#hOBtJG%+v5L**&_Oexp2C!2T~E|lJV%vlGB9}( zHpI?^P=OuJq!NJ}n|kD4gDE->0C&MRc%0tDEKGX0oDXq}vK5t+IDNCbrwC=y3*Mw0 zn7{7(6Yfa`Vu?4IO8q*8l~5sxtsy1M2Hx_q%svKc$hp9V-dp!9oR>XE_`4H_p9uQXR`?`X%ij1YHM**EQ)BVu) zPBuFq_5kmMQ&ei3e~DWR!?1p8zc=PVFl@?Yx+RUh?G4kG$T%?E5DI^aY94iAE9cSk ZCP<%80l(v>Q(}20#;>XCqXq*-ukw1~gpG8LDwjTgajs}zf delta 23 ecma!#=h>0#;>XCqXe?Z(xoRShKZ~@&WH$gy%myC- diff --git a/end2end-data/storage_export/blobs/702c0558-bd86-438b-90df-670579ada7d3 b/end2end-data/storage_export/blobs/702c0558-bd86-438b-90df-670579ada7d3 deleted file mode 100644 index 2b7e4504b4cc53cc660987149321c88ca664a8d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2455 zcmbtTc~q0f7M~Ds1Cd~W2!gwkqClWXf}n`9xYPiFV1z(Jz<^C6320eFTX}9YNZ$k7 z$}TEfM9Z!SLM4I+!!AJrh(Xzc$|4|&n9c|7f&Sq;=bd-we0P3x?{9u{XZaM*6fc0r zeusSy0EGeo1shQGpq}m9v&YBTmF%$Zp#5CpF@UCQ3IK;2!gqDFTYH#xWbNV?b2E?} zgBcuz^j|QjJ1Ifb0chFw1jO#8}oGHbk)R9JWUA_aQ+c zkmHPCzMJbFi2WcoWG%qH3otW?kMISMW6fcoK-NMGsaVaQ>rR3F68NnK!N3(b0y|{? za18BuIsn880MI|r$9%5>P;w3coOC|6F%5tf{{o=6VLmp0CP557V^&TXdZnXB0T{~! zU^xwdm5l%_^PJ5=_l4Tl!qjS5*KycbfDQb?TCfjr0TUQQWClzD0c=-vfIUD%RZU%O znTGmubxn-MN}QoCPFovC*sw|8(8}D#nq*G2v~}`2WV_eH-je9daq;l+Wd*Qye!~k3 z@(cCyXE6~G6h>1Mr-d`o)iq(-5$%}&vneV8W(mB^LNsbUP{N?l7?eT;^x$!$QPBS0 zEPQe*i_l7_#V|?IfUgs!qN2P6z4R{vN(rruarw8(N~{*;YyN-oG}jT#U4wsYGTVK2 z(FTjX4BoZ;KQ_XttDqv(omYkTidI%qfvNQv6i`AbFI*L+gh6YrbRn4S#;#LlD5O9Q z4LKMz2J8Y-AiA|s=KsddqwiC04XT@jX2u_soiYraZlH!l#gUJl#P zn$>r{YfWC;pIxf+@WO18ILk}P81PwUFq@mBg(qJ7hXzUhPldsRb?OdDaaMG+gW(A7_b@Hm~>h6X%l3ZB~tb^>ipu z`bdv%2IRIo8j{2p5c)Y2G`-8gPIIklOeST*3$w69#RJ&Lw;q^eqrRbnZ zQSVG=SW(3vtQ#|(#lLb3KZe;EHQ(sT(|l`$|0L~xQP6z42Mn7g=g$xq!Jjm$XnLE? z)L8th<#%>{2pq17zix`>aPWBriTg*#nH^^>&F=S8V?7;zmquF4obn5Pqmflp{J*cy zRYx4QD)YdCQKfO%m2Rs-65=S}UZ2M)c_ZSQ4l4CurN0yIL7ctmLV-%FZVtY<+#}FY zmSkEpO=OtZTFW*xu4t}wyFvJoTr}-&^Z4D*p6&$sL>kAtj(pnsrW-*%U3;HAp%XBv zN6t*b>qPujUCZa=_EK-4t3Cfx(C&}Ctt2UUI$fS4EKd@P#Vsq%$DK`XcO^WzXFxXI zW|Lo4zVneo+Z(s{8|ta!(^`5yMU>8168Fxn9lKF+9?!izYqvMPrlyjKt}hyow6}c6 z`r2cPC5?S@TLG3INiPwR#MvWZbpB996*Xt((W*nj@i$4r^2|aPBc1~#`|fpoE7$#P zNkBTaN03QDLUAYV+iR9!{jCzC&5{o$=1L8(K-SYvmw35f7q@9|E)>Z?$eV{wMsMO8 z)}FA0QtXC`_tj;tSX6mLr*qUn;AWort zfWlAvPK3O4L$zsHnWiF)+fLaIkMEqbssJjse;@4%uiMj&sn|%VQLYxPiye?-nv;OP z6gw1^B}}NJVf7-4o5o(&d&sxlV+GR3E~Q83#B_%D5I*%%gGqK-A)THf!jUI+)RXZJ z7ESJiq-s^8Yy$3(x6c|Mo#vJRafb2@w;S!$JEHL6_s5J9juk!3VJ#JG>N$C{cFWQd zK}5kM#e14wA{N#yYkR)Gc2HEB5%2lrKpmGJI?~WC9gg_DJlyjqGP$X0ja#)^^w4DR zhu9VcI1ng;7jXTErcw#BN=#3wSRl1l~^;IN&KE|2g_ z=>XMtQ&wMzaakzqwOLQ-wOuK9+HAuv13=}xedH*P#nZ}K?JcUcTC^M}xG00Ewzpfu z-9o?ZJUpH?@=$t=BiOR_Eakx6yg?V)`9QW0Ki5alHC)tjs%7zMh(!EZMoH>H0H-k7wi^7xD($rx@~|tPE=!(-U};GMx5PF~d^Q z<;AF;dCM$2n>@SaU~&M3900;i?AhoiVS?&spSN&6o{?xI>fU1K2%B^2FJ1lA_uDDi z>+tQTadh3g0!BvZCsUm~_OopDS1r6w;vK6i z+o8Tgo*xxSb*|8SdAYvo^2b|g-R|+YxXTTe!@>HmupPALE!bAMfHy(n;_E3sz3Fsn zoc1M0O-pS5=ANY9f?skIWkepFqbJWlzxBWh{p1V!6SUnAgocDIbJ*7R&<=|i1Y=}% olz{!LI%ZAw4=RJmaMswB-|`WF# Date: Fri, 20 Dec 2024 03:57:52 +0100 Subject: [PATCH 14/15] Fix: all tests passing --- .../quickfix/ui/search/ProfileResultsTest.kt | 135 ++-- .../search/QuickFixSlidingWindowWorkerTest.kt | 20 +- .../ui/search/SearchOnBoardingTest.kt | 117 +++- .../ui/search/SearchWorkerResultScreenTest.kt | 96 +-- .../userModeUI/search/ProfileResults.kt | 107 ++-- .../userModeUI/search/QuickFixFinder.kt | 125 ++-- .../search/QuickFixSlidingWindowWorker.kt | 26 +- .../userModeUI/search/SearchOnBoarding.kt | 237 +++---- .../userModeUI/search/SearchWorkerResult.kt | 592 +++++++++--------- 9 files changed, 724 insertions(+), 731 deletions(-) diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt index 16e4e118..78067b2b 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/ProfileResultsTest.kt @@ -1,106 +1,45 @@ package com.arygm.quickfix.ui.search import android.graphics.Bitmap -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp import com.arygm.quickfix.model.account.Account -import com.arygm.quickfix.model.account.AccountRepositoryFirestore import com.arygm.quickfix.model.account.AccountViewModel -import com.arygm.quickfix.model.category.CategoryRepositoryFirestore -import com.arygm.quickfix.model.category.CategoryViewModel -import com.arygm.quickfix.model.profile.ProfileRepository -import com.arygm.quickfix.model.profile.ProfileViewModel +import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.WorkerProfile -import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.search.SearchViewModel -import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.ProfileResults import com.google.firebase.Timestamp import io.mockk.every -import io.mockk.invoke import io.mockk.mockk import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.Mockito.mock class ProfileResultsTest { - private lateinit var navigationActions: NavigationActions - private lateinit var workerProfileRepo: WorkerProfileRepositoryFirestore - private lateinit var accountRepositoryFirestore: AccountRepositoryFirestore - private lateinit var categoryRepo: CategoryRepositoryFirestore - private lateinit var searchViewModel: SearchViewModel - private lateinit var accountViewModel: AccountViewModel - private lateinit var categoryViewModel: CategoryViewModel - private lateinit var navigationActionsRoot: NavigationActions - private lateinit var workerRepository: ProfileRepository - private lateinit var workerViewModel: ProfileViewModel - @get:Rule val composeTestRule = createComposeRule() + private lateinit var accountViewModel: AccountViewModel + private lateinit var searchViewModel: SearchViewModel + @Before fun setup() { - navigationActions = mock(NavigationActions::class.java) - navigationActionsRoot = mock(NavigationActions::class.java) - workerProfileRepo = mockk(relaxed = true) - categoryRepo = mockk(relaxed = true) - accountRepositoryFirestore = mock(AccountRepositoryFirestore::class.java) - searchViewModel = SearchViewModel(workerProfileRepo) - categoryViewModel = CategoryViewModel(categoryRepo) accountViewModel = mockk(relaxed = true) + searchViewModel = mockk(relaxed = true) - workerViewModel = mockk(relaxed = true) - - // Mock fetchProfileImageAsBitmap - every { workerViewModel.fetchProfileImageAsBitmap(any(), any(), any()) } answers - { - val onSuccess = arg<(Bitmap) -> Unit>(1) - // Provide a dummy bitmap here (e.g. a solid color bitmap or decode from resources) - val dummyBitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888) - onSuccess(dummyBitmap) // Simulate success callback - } - - // Mock fetchBannerImageAsBitmap - every { workerViewModel.fetchBannerImageAsBitmap(any(), any(), any()) } answers - { - val onSuccess = arg<(Bitmap) -> Unit>(1) - // Provide another dummy bitmap - val dummyBitmap = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888) - onSuccess(dummyBitmap) // Simulate success callback - } - } + // Mock calculateDistance to return a fixed distance + every { searchViewModel.calculateDistance(any(), any(), any(), any()) } returns 10.0 - @Test - fun profileContent_displaysWorkerProfiles() { - // Set up test data - val testProfiles = - listOf( - WorkerProfile( - uid = "worker0", - fieldOfWork = "Plumbing", - rating = 3.5, - reviews = ArrayDeque(), - location = null, // Simplify for the test - price = 49.0, - ), - WorkerProfile( - uid = "worker1", - fieldOfWork = "Electrical", - rating = 3.0, - reviews = ArrayDeque(), - location = null, - price = 59.0, - )) - - // Mock the AccountViewModel to return sample account data + // Mock AccountViewModel fetchUserAccount every { accountViewModel.fetchUserAccount(any(), captureLambda()) } answers { val uid = firstArg() - val account = + val lambda = secondArg<(Account?) -> Unit>() + lambda( when (uid) { "worker0" -> Account( @@ -108,40 +47,70 @@ class ProfileResultsTest { firstName = "John", lastName = "Doe", email = "", - Timestamp.now()) + birthDate = Timestamp.now()) "worker1" -> Account( uid = "worker1", firstName = "Jane", lastName = "Smith", email = "", - Timestamp.now()) + birthDate = Timestamp.now()) else -> null - } - lambda<(Account?) -> Unit>().invoke(account) + }) } + } + + @Test + fun profileResults_displaysWorkerProfiles_correctly() { + // Test data: profiles, images, and location + val testProfiles = + listOf( + WorkerProfile( + uid = "worker0", + displayName = "John Doe", + fieldOfWork = "Plumbing", + reviews = ArrayDeque(listOf()), + location = Location(40.0, 70.0), + price = 49.0), + WorkerProfile( + uid = "worker1", + displayName = "Jane Smith", + fieldOfWork = "Electrical", + reviews = ArrayDeque(listOf()), + location = Location(41.0, 71.0), + price = 59.0)) + + val dummyBitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val profileImagesMap = mapOf("worker0" to dummyBitmap, "worker1" to dummyBitmap) + val bannerImagesMap = mapOf("worker0" to dummyBitmap, "worker1" to dummyBitmap) + val baseLocation = Location(0.0, 0.0) - // Set the content with ProfileContent composable + // Set the ProfileResults composable composeTestRule.setContent { ProfileResults( profiles = testProfiles, - listState = rememberLazyListState(), searchViewModel = searchViewModel, accountViewModel = accountViewModel, - workerViewModel = workerViewModel, - onBookClick = { _, _ -> }) + onBookClick = { _, _, _, _ -> }, + profileImagesMap = profileImagesMap, + bannerImagesMap = bannerImagesMap, + baseLocation = baseLocation, + screenHeight = 1000.dp) } // Allow coroutines to complete composeTestRule.waitForIdle() - // Verify that the profile items are displayed correctly - composeTestRule.onNodeWithTag("worker_profile_result_0").assertIsDisplayed() + // Verify first profile + composeTestRule.onNodeWithTag("worker_profile_result0").assertIsDisplayed() composeTestRule.onNodeWithText("John Doe").assertIsDisplayed() composeTestRule.onNodeWithText("Plumbing").assertIsDisplayed() + composeTestRule.onNodeWithText("49.0").assertIsDisplayed() - composeTestRule.onNodeWithTag("worker_profile_result_1").assertIsDisplayed() + // Verify second profile + composeTestRule.onNodeWithTag("worker_profile_result1").assertIsDisplayed() composeTestRule.onNodeWithText("Jane Smith").assertIsDisplayed() composeTestRule.onNodeWithText("Electrical").assertIsDisplayed() + composeTestRule.onNodeWithText("59.0").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt index 0a037583..6527e285 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/QuickFixSlidingWindowWorkerTest.kt @@ -1,11 +1,11 @@ package com.arygm.quickfix.ui.search +import android.graphics.Bitmap import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.unit.dp -import com.arygm.quickfix.R import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.QuickFixSlidingWindowWorker import org.junit.Rule import org.junit.Test @@ -37,11 +37,11 @@ class QuickFixSlidingWindowWorkerTest { QuickFixSlidingWindowWorker( isVisible = true, onDismiss = { /* No-op */}, - bannerImage = R.drawable.moroccan_flag, - profilePicture = R.drawable.placeholder_worker, + bannerImage = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888), + profilePicture = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888), initialSaved = false, workerCategory = "Painter", - workerAddress = "123 Main Street", + selectedCityName = "123 Main Street", description = "Sample description for the worker.", includedServices = includedServices, addonServices = addonServices, @@ -92,11 +92,11 @@ class QuickFixSlidingWindowWorkerTest { QuickFixSlidingWindowWorker( isVisible = true, onDismiss = { /* No-op */}, - bannerImage = R.drawable.moroccan_flag, - profilePicture = R.drawable.placeholder_worker, + bannerImage = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888), + profilePicture = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888), initialSaved = false, workerCategory = "Painter", - workerAddress = "123 Main Street", + selectedCityName = "123 Main Street", description = "Sample description for the worker.", includedServices = includedServices, addonServices = addOnServices, @@ -155,11 +155,11 @@ class QuickFixSlidingWindowWorkerTest { QuickFixSlidingWindowWorker( isVisible = true, onDismiss = { /* No-op */}, - bannerImage = R.drawable.moroccan_flag, - profilePicture = R.drawable.placeholder_worker, + bannerImage = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888), + profilePicture = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888), initialSaved = false, workerCategory = "Painter", - workerAddress = "123 Main Street", + selectedCityName = "123 Main Street", description = "Sample description for the worker.", includedServices = includedServices, addonServices = addOnServices, diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt index c4723ed7..f0705145 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchOnBoardingTest.kt @@ -14,22 +14,26 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.printToLog import androidx.compose.ui.text.AnnotatedString +import androidx.datastore.preferences.core.Preferences import com.arygm.quickfix.model.account.AccountRepositoryFirestore import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryRepositoryFirestore import com.arygm.quickfix.model.category.CategoryViewModel import com.arygm.quickfix.model.category.Subcategory import com.arygm.quickfix.model.locations.Location -import com.arygm.quickfix.model.profile.WorkerProfile +import com.arygm.quickfix.model.offline.small.PreferencesRepository +import com.arygm.quickfix.model.offline.small.PreferencesViewModel +import com.arygm.quickfix.model.profile.Profile import com.arygm.quickfix.model.profile.ProfileRepository import com.arygm.quickfix.model.profile.ProfileViewModel +import com.arygm.quickfix.model.profile.UserProfile +import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.profile.WorkerProfileRepositoryFirestore import com.arygm.quickfix.model.quickfix.QuickFixViewModel import com.arygm.quickfix.model.search.SearchViewModel @@ -40,11 +44,16 @@ import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchOnBoard import io.mockk.mockk import java.time.LocalDate import java.time.LocalTime +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.doAnswer import org.mockito.Mockito.mock import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever class SearchOnBoardingTest { @@ -55,7 +64,11 @@ class SearchOnBoardingTest { private lateinit var searchViewModel: SearchViewModel private lateinit var accountViewModel: AccountViewModel private lateinit var categoryViewModel: CategoryViewModel + private lateinit var userViewModel: ProfileViewModel + private lateinit var preferencesViewModel: PreferencesViewModel private lateinit var navigationActionsRoot: NavigationActions + private lateinit var preferencesRepositoryDataStore: PreferencesRepository + private lateinit var userProfileRepositoryFirestore: ProfileRepository private lateinit var quickFixViewModel: QuickFixViewModel private lateinit var workerViewModel: ProfileViewModel private lateinit var workerProfileRepository: ProfileRepository @@ -72,9 +85,34 @@ class SearchOnBoardingTest { categoryRepo = mockk(relaxed = true) accountRepositoryFirestore = mock(AccountRepositoryFirestore::class.java) searchViewModel = SearchViewModel(workerProfileRepo) + userProfileRepositoryFirestore = mock(ProfileRepository::class.java) categoryViewModel = CategoryViewModel(categoryRepo) accountViewModel = mockk(relaxed = true) quickFixViewModel = QuickFixViewModel(mock()) + userViewModel = ProfileViewModel(userProfileRepositoryFirestore) + + preferencesRepositoryDataStore = mock(PreferencesRepository::class.java) + + val mockedPreferenceFlow = MutableStateFlow(null) + whenever(preferencesRepositoryDataStore.getPreferenceByKey(any>())) + .thenReturn(mockedPreferenceFlow) + preferencesViewModel = PreferencesViewModel(preferencesRepositoryDataStore) + doAnswer { invocation -> + val uid = invocation.arguments[0] as String + val onSuccess = invocation.arguments[1] as (Profile?) -> Unit + val onFailure = invocation.arguments[2] as (Exception) -> Unit + + // Return a user profile with a "Home" location + val testUserProfile = + UserProfile( + locations = listOf(Location(latitude = 40.0, longitude = -74.0, name = "Home")), + announcements = emptyList(), + uid = uid) + onSuccess(testUserProfile) + null + } + .`when`(userProfileRepositoryFirestore) + .getProfileById(anyString(), any(), any()) } @Test @@ -85,9 +123,10 @@ class SearchOnBoardingTest { navigationActionsRoot, searchViewModel, accountViewModel, + preferencesViewModel, + userViewModel, categoryViewModel, - onProfileClick = { _, _ -> }, - + onBookClick = { _, _, _, _ -> }, workerViewModel) } @@ -107,8 +146,10 @@ class SearchOnBoardingTest { navigationActionsRoot, searchViewModel, accountViewModel, + preferencesViewModel, + userViewModel, categoryViewModel, - onProfileClick = { _, _ -> }, + onBookClick = { _, _, _, _ -> }, workerViewModel) } @@ -137,8 +178,10 @@ class SearchOnBoardingTest { navigationActionsRoot, searchViewModel, accountViewModel, + preferencesViewModel, + userViewModel, categoryViewModel, - onProfileClick = { _, _ -> }, + onBookClick = { _, _, _, _ -> }, workerViewModel) } @@ -158,8 +201,10 @@ class SearchOnBoardingTest { navigationActionsRoot, searchViewModel, accountViewModel, + preferencesViewModel, + userViewModel, categoryViewModel, - onProfileClick = { _, _ -> }, + onBookClick = { _, _, _, _ -> }, workerViewModel) } @@ -199,12 +244,14 @@ class SearchOnBoardingTest { // Set the composable content composeTestRule.setContent { SearchOnBoarding( - navigationActions = navigationActions, - navigationActionsRoot = navigationActionsRoot, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - categoryViewModel = categoryViewModel, - onProfileClick = { _, _ -> }, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + preferencesViewModel, + userViewModel, + categoryViewModel, + onBookClick = { _, _, _, _ -> }, workerViewModel) } @@ -274,16 +321,16 @@ class SearchOnBoardingTest { // Set the composable content composeTestRule.setContent { SearchOnBoarding( - navigationActions = navigationActions, - navigationActionsRoot = navigationActionsRoot, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - categoryViewModel = categoryViewModel, - onProfileClick = { _, _ -> }, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + preferencesViewModel, + userViewModel, + categoryViewModel, + onBookClick = { _, _, _, _ -> }, workerViewModel) - } - // Perform a search query to display filter buttons composeTestRule.onNodeWithTag("searchContent").performTextInput("Painter") composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -313,12 +360,14 @@ class SearchOnBoardingTest { // Set the composable content composeTestRule.setContent { SearchOnBoarding( - navigationActions = navigationActions, - navigationActionsRoot = navigationActionsRoot, - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - categoryViewModel = categoryViewModel, - onProfileClick = { _, _ -> }, + navigationActions, + navigationActionsRoot, + searchViewModel, + accountViewModel, + preferencesViewModel, + userViewModel, + categoryViewModel, + onBookClick = { _, _, _, _ -> }, workerViewModel) } @@ -331,8 +380,6 @@ class SearchOnBoardingTest { composeTestRule.onNodeWithText("Location").performClick() // Verify the bottom sheet appears - composeTestRule.waitForIdle() - composeTestRule.onRoot().printToLog("root") composeTestRule.onNodeWithTag("locationFilterModalSheet").assertIsDisplayed() } @@ -344,8 +391,10 @@ class SearchOnBoardingTest { navigationActionsRoot, searchViewModel, accountViewModel, + preferencesViewModel, + userViewModel, categoryViewModel, - onProfileClick = { _, _ -> }, + onBookClick = { _, _, _, _ -> }, workerViewModel) } @@ -368,8 +417,10 @@ class SearchOnBoardingTest { navigationActionsRoot, searchViewModel, accountViewModel, + preferencesViewModel, + userViewModel, categoryViewModel, - onProfileClick = { _, _ -> }, + onBookClick = { _, _, _, _ -> }, workerViewModel) } @@ -396,8 +447,10 @@ class SearchOnBoardingTest { navigationActionsRoot, searchViewModel, accountViewModel, + preferencesViewModel, + userViewModel, categoryViewModel, - onProfileClick = { _, _ -> }, + onBookClick = { _, _, _, _ -> }, workerViewModel) } diff --git a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt index 4cc0fec6..ed24aa94 100644 --- a/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt +++ b/app/src/androidTest/java/com/arygm/quickfix/ui/search/SearchWorkerResultScreenTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.filter +import androidx.compose.ui.test.hasAnyChild import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasParent import androidx.compose.ui.test.hasSetTextAction @@ -22,7 +23,6 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag @@ -222,26 +222,6 @@ class SearchWorkerResultScreenTest { composeTestRule.onNodeWithContentDescription("Search").assertExists().assertIsDisplayed() } - @Test - fun testTitleAndDescriptionAreDisplayed() { - // Set the composable content - composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - quickFixViewModel, - preferencesViewModel, - workerViewModel = workerViewModel) - } - // Set the search query and verify that the title and description match the query - searchViewModel.setSearchQuery("Unknown") - - // Check if the description with the query text is displayed - composeTestRule.onAllNodesWithText("Unknown").assertCountEquals(3) - } - @Test fun testFilterButtonsAreDisplayed() { // Set the composable content @@ -291,7 +271,7 @@ class SearchWorkerResultScreenTest { searchViewModel, accountViewModel, userViewModel, - quickFixViewModel, + quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) } @@ -315,7 +295,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Scroll through the LazyColumn and verify each profile result is displayed val workerProfilesList = composeTestRule.onNodeWithTag("worker_profiles_list") @@ -341,7 +320,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Perform click on the back button and verify goBack() is called composeTestRule.onNodeWithContentDescription("Back").performClick() @@ -360,7 +338,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Wait for the UI to settle @@ -391,7 +368,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Wait until the worker profiles are displayed @@ -419,7 +395,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Wait until the worker profiles are displayed @@ -450,7 +425,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Wait until the worker profiles are displayed @@ -489,7 +463,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Wait until the worker profiles are displayed @@ -526,7 +499,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Wait until the worker profiles are displayed @@ -564,7 +536,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Wait until the worker profiles are displayed @@ -604,7 +575,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Wait until the worker profiles are displayed @@ -636,7 +606,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Wait until the worker profiles are displayed @@ -725,7 +694,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Initially, all workers should be displayed @@ -819,7 +787,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } // Initially, all workers should be displayed @@ -905,16 +872,14 @@ class SearchWorkerResultScreenTest { // Set the composable content composeTestRule.setContent { - SearchWorkerResult( - navigationActions, - searchViewModel, - accountViewModel, - userViewModel, - quickFixViewModel, - preferencesViewModel, - workerViewModel = workerViewModel - ) - + SearchWorkerResult( + navigationActions, + searchViewModel, + accountViewModel, + userViewModel, + quickFixViewModel, + preferencesViewModel, + workerViewModel = workerViewModel) } // Initially, all workers should be displayed @@ -1000,7 +965,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1069,7 +1033,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1088,7 +1051,8 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(sortedWorkers.size) sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + workerNodes[index].assert( + hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) } } @@ -1138,7 +1102,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1165,7 +1128,8 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(filteredWorkers.size) filteredWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + workerNodes[index].assert( + hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) } } @@ -1209,7 +1173,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1238,7 +1201,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1289,7 +1251,6 @@ class SearchWorkerResultScreenTest { quickFixViewModel, preferencesViewModel, workerViewModel = workerViewModel) - } composeTestRule.onNodeWithTag("tuneButton").performClick() @@ -1309,9 +1270,11 @@ class SearchWorkerResultScreenTest { val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() workerNodes.assertCountEquals(sortedWorkers.size) + workerNodes[0].printToLog("WorkerNode") sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + workerNodes[index].assert( + hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) } } @@ -1372,7 +1335,8 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(sortedWorkers.size) sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + workerNodes[index].assert( + hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) } } @@ -1661,9 +1625,9 @@ class SearchWorkerResultScreenTest { val workerNodes = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() workerNodes.assertCountEquals(workers.size) // Verify order by rating text - workerNodes[0].assert(hasText("4.5 ★", substring = true)) - workerNodes[1].assert(hasText("3.0 ★", substring = true)) - workerNodes[2].assert(hasText("2.0 ★", substring = true)) + workerNodes[0].assert(hasAnyChild(hasText("4.5 ★", substring = true))) + workerNodes[1].assert(hasAnyChild(hasText("3.0 ★", substring = true))) + workerNodes[2].assert(hasAnyChild(hasText("2.0 ★", substring = true))) // Click again to remove Highest Rating filter composeTestRule.onNodeWithText("Highest Rating").performClick() @@ -1675,9 +1639,10 @@ class SearchWorkerResultScreenTest { // Check that the initial worker (w1) is now first again. val workerNodesAfterRevert = composeTestRule.onNodeWithTag("worker_profiles_list").onChildren() - workerNodesAfterRevert[0].assert(hasText("3.0 ★", substring = true)) // w1 first again - workerNodesAfterRevert[1].assert(hasText("4.5 ★", substring = true)) - workerNodesAfterRevert[2].assert(hasText("2.0 ★", substring = true)) + workerNodesAfterRevert[0].assert( + hasAnyChild(hasText("3.0 ★", substring = true))) // w1 first again + workerNodesAfterRevert[1].assert(hasAnyChild(hasText("4.5 ★", substring = true))) + workerNodesAfterRevert[2].assert(hasAnyChild(hasText("2.0 ★", substring = true))) } @Test @@ -1822,7 +1787,8 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(sortedWorkers.size) sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + workerNodes[index].assert( + hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) } } @@ -2132,7 +2098,8 @@ class SearchWorkerResultScreenTest { workerNodes.assertCountEquals(sortedWorkers.size) sortedWorkers.forEachIndexed { index, worker -> - workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + workerNodes[index].assert( + hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) } composeTestRule.onNodeWithText("Emergency").performClick() @@ -2144,7 +2111,8 @@ class SearchWorkerResultScreenTest { workerNodes1.assertCountEquals(sortedWorkers.size) sortedWorkers1.forEachIndexed { index, worker -> - workerNodes[index].assert(hasText("${worker.price.roundToInt()}", substring = true)) + workerNodes[index].assert( + hasAnyChild(hasText("${worker.price.roundToInt()}", substring = true))) } } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt index b4f941b7..5383d9c7 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/ProfileResults.kt @@ -1,11 +1,10 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search import android.graphics.Bitmap -import android.util.Log -import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -15,26 +14,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import com.arygm.quickfix.MainActivity +import androidx.compose.ui.unit.Dp import com.arygm.quickfix.model.account.Account import com.arygm.quickfix.model.account.AccountViewModel -import com.arygm.quickfix.model.profile.ProfileViewModel +import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.utils.GeocoderWrapper -import com.arygm.quickfix.utils.LocationHelper -import kotlin.math.roundToInt @Composable fun ProfileResults( modifier: Modifier = Modifier, profiles: List, - listState: LazyListState, searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, - workerViewModel: ProfileViewModel, + baseLocation: Location, + profileImagesMap: Map, + bannerImagesMap: Map, + screenHeight: Dp, geocoderWrapper: GeocoderWrapper = GeocoderWrapper(LocalContext.current), - onBookClick: (WorkerProfile, String) -> Unit + onBookClick: (WorkerProfile, String, Bitmap, Bitmap) -> Unit, ) { fun getCityNameFromCoordinates(latitude: Double, longitude: Double): String? { val addresses = geocoderWrapper.getFromLocation(latitude, longitude, 1) @@ -43,69 +42,59 @@ fun ProfileResults( ?: addresses?.firstOrNull()?.adminArea } - val context = LocalContext.current - val locationHelper = remember { LocationHelper(context, MainActivity()) } - - LazyColumn(modifier = modifier.fillMaxWidth().testTag("worker_profiles_list"), state = listState) { + LazyColumn(modifier = modifier.fillMaxWidth().testTag("worker_profiles_list")) { items(profiles.size) { index -> val profile = profiles[index] - var account by remember { mutableStateOf(null) } var distance by remember { mutableStateOf(null) } - var profileImage by remember { mutableStateOf(null) } var cityName by remember { mutableStateOf(null) } + val profileImage = profileImagesMap[profile.uid] + val bannerImage = bannerImagesMap[profile.uid] + distance = + profile.location + ?.let { workerLocation -> + searchViewModel.calculateDistance( + workerLocation.latitude, + workerLocation.longitude, + baseLocation.latitude, + baseLocation.longitude) + } + ?.toInt() - // Fetch data once using LaunchedEffect, keyed by profile.uid LaunchedEffect(profile.uid) { - // Fetch profile image once - workerViewModel.fetchProfileImageAsBitmap( - profile.uid, - onSuccess = { profileImage = it }, - onFailure = { Log.e("ProfileResults", "Failed to fetch profile image: $it") }) - - // Fetch account data once - accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount -> + accountViewModel.fetchUserAccount(profile.uid) { fetchedAccount: Account? -> account = fetchedAccount } + } - // Get current location once - locationHelper.getCurrentLocation { location -> - location?.let { - distance = - profile.location?.let { workerLocation -> - searchViewModel - .calculateDistance( - workerLocation.latitude, - workerLocation.longitude, - it.latitude, - it.longitude) - .toInt() - } - } + account?.let { acc -> + val locationName = + if (profile.location?.name.isNullOrEmpty()) "Unknown" else profile.location?.name - // Compute city name once + locationName?.let { cityName = - profile.location?.let { loc -> - getCityNameFromCoordinates(loc.latitude, loc.longitude) - } ?: "Unknown" + profile.location?.let { it1 -> + getCityNameFromCoordinates(it1.latitude, profile.location.longitude) + } + cityName?.let { it1 -> + profileImage?.let { it2 -> + SearchWorkerProfileResult( + modifier = Modifier.testTag("worker_profile_result$index"), + profileImage = it2, + name = profile.displayName, + category = profile.fieldOfWork, + rating = profile.reviews.map { review -> review.rating }.average(), + reviewCount = profile.reviews.size, + location = it1, + price = profile.price.toString(), + onBookClick = { onBookClick(profile, it1, it2, bannerImage!!) }, + distance = distance, + ) + } + } } } - - // Only show the result once all required data is available - val displayLoc = cityName ?: "Unknown" - if (account != null && profileImage != null) { - SearchWorkerProfileResult( - modifier = Modifier.fillMaxWidth().testTag("worker_profile_result_$index").clickable {}, - profileImage = profileImage!!, - name = "${account!!.firstName} ${account!!.lastName}", - category = profile.fieldOfWork, - rating = profile.rating, - reviewCount = profile.reviews.size, - location = displayLoc, - price = profile.price.roundToInt().toString(), - distance = distance, - onBookClick = { onBookClick(profile, displayLoc) }) - } + Spacer(modifier = Modifier.height(screenHeight * 0.004f)) } } } diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt index 3898bea7..745c7549 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt @@ -1,5 +1,6 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search +import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -36,7 +37,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel import com.arygm.quickfix.model.offline.small.PreferencesViewModel @@ -47,8 +47,6 @@ import com.arygm.quickfix.model.search.AnnouncementViewModel import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScreen -import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.AnnouncementScreen -import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search.SearchOnBoarding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -69,78 +67,87 @@ fun QuickFixFinderScreen( workerViewModel: ProfileViewModel ) { var isWindowVisible by remember { mutableStateOf(false) } - var pager by remember { mutableStateOf(true) } + var selectedCityName by remember { mutableStateOf(null) } + var selectedWorker by remember { mutableStateOf(WorkerProfile()) } val pagerState = rememberPagerState(pageCount = { 2 }) val colorBackground = if (pagerState.currentPage == 0) colorScheme.background else colorScheme.surface val colorButton = if (pagerState.currentPage == 1) colorScheme.background else colorScheme.surface - var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerAddress by remember { mutableStateOf("") } + val defaultBitmap = + Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) // Example default Bitmap + + var profilePicture by remember { mutableStateOf(defaultBitmap) } + var bannerPicture by remember { mutableStateOf(defaultBitmap) } + var initialSaved by remember { mutableStateOf(false) } BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val screenHeight = maxHeight val screenWidth = maxWidth - Scaffold( - containerColor = colorBackground, - topBar = { - TopAppBar( - title = { - val coroutineScope = rememberCoroutineScope() - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxSize().padding(end = 20.dp)) { - Surface( - color = colorButton, - shape = RoundedCornerShape(20.dp), - modifier = - Modifier.padding(horizontal = 40.dp).clip(RoundedCornerShape(20.dp))) { - TabRow( - selectedTabIndex = pagerState.currentPage, - containerColor = Color.Transparent, - divider = {}, - indicator = {}, - modifier = - Modifier.padding(horizontal = 1.dp, vertical = 1.dp) - .testTag("quickFixSearchTabRow")) { - QuickFixScreenTab(pagerState, coroutineScope, 0, "Search", screenWidth) - QuickFixScreenTab(pagerState, coroutineScope, 1, "Announce", screenWidth) - } - } - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = colorBackground), - modifier = Modifier.testTag("QuickFixFinderTopBar")) - }, - content = { padding -> - Column( - modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally) { - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - modifier = Modifier.testTag("quickFixSearchPager")) { page -> - when (page) { - 0 -> { + Scaffold( + containerColor = colorBackground, + topBar = { + TopAppBar( + title = { + val coroutineScope = rememberCoroutineScope() + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize().padding(end = 20.dp)) { + Surface( + color = colorButton, + shape = RoundedCornerShape(20.dp), + modifier = + Modifier.padding(horizontal = 40.dp) + .clip(RoundedCornerShape(20.dp))) { + TabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = Color.Transparent, + divider = {}, + indicator = {}, + modifier = + Modifier.padding(horizontal = 1.dp, vertical = 1.dp) + .testTag("quickFixSearchTabRow")) { + QuickFixScreenTab( + pagerState, coroutineScope, 0, "Search", screenWidth) + QuickFixScreenTab( + pagerState, coroutineScope, 1, "Announce", screenWidth) + } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = colorBackground), + modifier = Modifier.testTag("QuickFixFinderTopBar")) + }, + content = { padding -> + Column( + modifier = Modifier.fillMaxSize().testTag("QuickFixFinderContent").padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.testTag("quickFixSearchPager")) { page -> + when (page) { + 0 -> { SearchOnBoarding( navigationActions, navigationActionsRoot, searchViewModel, accountViewModel, + preferencesViewModel, + profileViewModel, categoryViewModel, - onProfileClick = { profile, locName -> - selectedWorker = profile - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker + onBookClick = { selectedProfile, locName, profile, banner -> + bannerPicture = banner + profilePicture = profile initialSaved = false - workerAddress = locName + selectedCityName = locName isWindowVisible = true - }, workerViewModel = workerViewModel) + selectedWorker = selectedProfile + }, + workerViewModel = workerViewModel) } 1 -> { AnnouncementScreen( @@ -167,11 +174,11 @@ fun QuickFixFinderScreen( quickFixViewModel.setSelectedWorkerProfile(selectedWorker) navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) }, - bannerImage = bannerImage, + bannerImage = bannerPicture, profilePicture = profilePicture, initialSaved = initialSaved, workerCategory = selectedWorker.fieldOfWork, - workerAddress = workerAddress, + selectedCityName = selectedCityName, description = selectedWorker.description, includedServices = selectedWorker.includedServices.map { it.name }, addonServices = selectedWorker.addOnServices.map { it.name }, diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt index e9f2a7e3..790317f7 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixSlidingWindowWorker.kt @@ -1,5 +1,6 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search +import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -39,9 +40,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.arygm.quickfix.ui.elements.QuickFixButton @@ -53,11 +55,11 @@ import com.arygm.quickfix.ui.elements.RatingBar fun QuickFixSlidingWindowWorker( isVisible: Boolean, onDismiss: () -> Unit, - bannerImage: Int, - profilePicture: Int, + bannerImage: Bitmap?, + profilePicture: Bitmap?, initialSaved: Boolean, workerCategory: String, - workerAddress: String, + selectedCityName: String?, description: String, includedServices: List, addonServices: List, @@ -88,7 +90,7 @@ fun QuickFixSlidingWindowWorker( .testTag("sliding_window_top_bar")) { // Banner Image Image( - painter = painterResource(id = bannerImage), + painter = BitmapPainter(bannerImage!!.asImageBitmap()), contentDescription = "Banner", modifier = Modifier.fillMaxWidth() @@ -113,7 +115,7 @@ fun QuickFixSlidingWindowWorker( // Profile picture overlapping the banner image Image( - painter = painterResource(id = profilePicture), + painter = BitmapPainter(profilePicture!!.asImageBitmap()), contentDescription = "Profile Picture", modifier = Modifier.size(screenHeight * 0.1f) @@ -135,11 +137,13 @@ fun QuickFixSlidingWindowWorker( style = MaterialTheme.typography.headlineLarge, color = colorScheme.onBackground, modifier = Modifier.testTag("sliding_window_worker_category")) - Text( - text = workerAddress, - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onBackground, - modifier = Modifier.testTag("sliding_window_worker_address")) + selectedCityName?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + color = colorScheme.onBackground, + modifier = Modifier.testTag("sliding_window_worker_address")) + } } // Main content should be scrollable diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index 92f15006..a6a61eaf 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -41,11 +41,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import com.arygm.quickfix.MainActivity import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel import com.arygm.quickfix.model.locations.Location -import com.arygm.quickfix.model.profile.UserProfile +import com.arygm.quickfix.model.offline.small.PreferencesViewModel import com.arygm.quickfix.model.profile.ProfileViewModel +import com.arygm.quickfix.model.profile.UserProfile import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.search.SearchViewModel import com.arygm.quickfix.ui.elements.ChooseServiceTypeSheet @@ -57,6 +59,8 @@ import com.arygm.quickfix.ui.elements.QuickFixTextFieldCustom import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserTopLevelDestinations +import com.arygm.quickfix.utils.LocationHelper +import com.arygm.quickfix.utils.loadUserId @Composable fun SearchOnBoarding( @@ -64,9 +68,12 @@ fun SearchOnBoarding( navigationActionsRoot: NavigationActions, searchViewModel: SearchViewModel, accountViewModel: AccountViewModel, + preferencesViewModel: PreferencesViewModel, + userProfileViewModel: ProfileViewModel, categoryViewModel: CategoryViewModel, - onProfileClick: (WorkerProfile, String) -> Unit, - workerViewModel: ProfileViewModel + onBookClick: (WorkerProfile, String, Bitmap, Bitmap) -> Unit, + workerViewModel: ProfileViewModel, + locationHelper: LocationHelper = LocationHelper(LocalContext.current, MainActivity()) ) { val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } val workerProfiles by searchViewModel.workerProfilesSuggestions.collectAsState() @@ -80,6 +87,8 @@ fun SearchOnBoarding( var selectedLocation by remember { mutableStateOf(Location()) } val categories = categoryViewModel.categories.collectAsState().value val itemCategories = remember { categories } + var uid by remember { mutableStateOf("Loading...") } + val expandedStates = remember { mutableStateListOf(*BooleanArray(itemCategories.size) { false }.toTypedArray()) } @@ -93,6 +102,22 @@ fun SearchOnBoarding( var baseLocation by remember { mutableStateOf(filterState.phoneLocation) } var maxDistance by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + if (locationHelper.checkPermissions()) { + locationHelper.getCurrentLocation { location -> + if (location != null) { + val userLoc = Location(location.latitude, location.longitude, "Phone Location") + filterState.phoneLocation = userLoc + } else { + Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() + } + } + } else { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } + uid = loadUserId(preferencesViewModel) + userProfileViewModel.fetchUserProfile(uid) { profile -> userProfile = profile as UserProfile } + } fun updateFilteredProfiles() { filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) } @@ -113,39 +138,39 @@ fun SearchOnBoarding( }, onShowLocationBottomSheet = { setUiState(uiState.copy(showLocationBottomSheet = true)) }) - val profileImagesMap by remember { mutableStateOf(mutableMapOf()) } - val bannerImagesMap by remember { mutableStateOf(mutableMapOf()) } - var loading by remember { mutableStateOf(true) } - // Tracks if data is loading - LaunchedEffect(workerProfiles) { - if (workerProfiles.isNotEmpty()) { - workerProfiles.forEach { profile -> - // Fetch profile images - workerViewModel.fetchProfileImageAsBitmap( - profile.uid, - onSuccess = { bitmap -> - profileImagesMap[profile.uid] = bitmap - checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { - loading = false - } - }, - onFailure = { Log.e("ProfileResults", "Failed to fetch profile image") }) + val profileImagesMap by remember { mutableStateOf(mutableMapOf()) } + val bannerImagesMap by remember { mutableStateOf(mutableMapOf()) } + var loading by remember { mutableStateOf(true) } + // Tracks if data is loading + LaunchedEffect(workerProfiles) { + if (workerProfiles.isNotEmpty()) { + workerProfiles.forEach { profile -> + // Fetch profile images + workerViewModel.fetchProfileImageAsBitmap( + profile.uid, + onSuccess = { bitmap -> + profileImagesMap[profile.uid] = bitmap + checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { + loading = false + } + }, + onFailure = { Log.e("ProfileResults", "Failed to fetch profile image") }) - // Fetch banner images - workerViewModel.fetchBannerImageAsBitmap( - profile.uid, - onSuccess = { bitmap -> - bannerImagesMap[profile.uid] = bitmap - checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { - loading = false - } - }, - onFailure = { Log.e("ProfileResults", "Failed to fetch banner image") }) - } - } else { - loading = false // No profiles to load - } + // Fetch banner images + workerViewModel.fetchBannerImageAsBitmap( + profile.uid, + onSuccess = { bitmap -> + bannerImagesMap[profile.uid] = bitmap + checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { + loading = false + } + }, + onFailure = { Log.e("ProfileResults", "Failed to fetch banner image") }) + } + } else { + loading = false // No profiles to load } + } BoxWithConstraints { val widthRatio = maxWidth.value / 411f val heightRatio = maxHeight.value / 860f @@ -156,40 +181,38 @@ fun SearchOnBoarding( Scaffold( containerColor = colorScheme.background, content = { padding -> - if (loading) { - // Display a loader - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - color = colorScheme.primary, modifier = Modifier.size(64.dp)) - } - } else { - Column( - modifier = + if (loading) { + // Display a loader + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = colorScheme.primary, modifier = Modifier.size(64.dp)) + } + } else { + Column( + modifier = Modifier.fillMaxWidth().padding(padding).padding(top = 40.dp * heightRatio), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 0.dp * heightRatio), - horizontalArrangement = Arrangement.Center - ) { + horizontalAlignment = Alignment.CenterHorizontally) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 0.dp * heightRatio), + horizontalArrangement = Arrangement.Center) { QuickFixTextFieldCustom( modifier = Modifier.testTag("searchContent"), showLeadingIcon = { true }, leadingIcon = Icons.Outlined.Search, showTrailingIcon = { searchQuery.isNotEmpty() }, trailingIcon = { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = "Clear search query", - tint = colorScheme.onBackground, - modifier = Modifier.testTag("clearSearchIcon"), - ) + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear search query", + tint = colorScheme.onBackground, + modifier = Modifier.testTag("clearSearchIcon"), + ) }, placeHolderText = "Find your perfect fix with QuickFix", value = searchQuery, onValueChange = { - searchQuery = it - searchViewModel.searchEngine(it) + searchQuery = it + searchViewModel.searchEngine(it) }, shape = CircleShape, textStyle = poppinsTypography.bodyMedium, @@ -213,63 +236,61 @@ fun SearchOnBoarding( buttonOpacity = 1f, textStyle = poppinsTypography.labelSmall, onClickAction = { - navigationActionsRoot.navigateTo(UserTopLevelDestinations.HOME) + navigationActionsRoot.navigateTo(UserTopLevelDestinations.HOME) }, contentPadding = PaddingValues(0.dp), ) - } - if (searchQuery.isEmpty()) { - // Show Categories - CategoryContent( - navigationActions = navigationActions, - searchViewModel = searchViewModel, - listState = listState, - expandedStates = expandedStates, - itemCategories = itemCategories, - widthRatio = widthRatio, - heightRatio = heightRatio, - ) - } else { - // Show Profiles - // Insert filter buttons here (only when searchQuery is not empty) - Column { - Row( - modifier = - Modifier.fillMaxWidth() - .padding( - top = screenHeight * 0.02f, - bottom = screenHeight * 0.01f - ) - .padding(horizontal = screenWidth * 0.02f), - verticalAlignment = Alignment.CenterVertically, - ) { - FilterRow( - showFilterButtons = uiState.showFilterButtons, - toggleFilterButtons = { - setUiState(uiState.copy(showFilterButtons = !uiState.showFilterButtons)) - }, - listOfButtons = listOfButtons, - modifier = Modifier.padding(bottom = screenHeight * 0.01f), - screenWidth = screenWidth, - screenHeight = screenHeight - ) - } - } - } - ProfileResults( - profiles = filteredWorkerProfiles, + } + if (searchQuery.isEmpty()) { + // Show Categories + CategoryContent( + navigationActions = navigationActions, searchViewModel = searchViewModel, - accountViewModel = accountViewModel, listState = listState, - onBookClick = { selectedProfile, loc -> - onProfileClick( - selectedProfile, - loc - ) - }, workerViewModel = workerViewModel + expandedStates = expandedStates, + itemCategories = itemCategories, + widthRatio = widthRatio, + heightRatio = heightRatio, ) + } else { + // Show Profiles + // Insert filter buttons here (only when searchQuery is not empty) + Column { + Row( + modifier = + Modifier.fillMaxWidth() + .padding( + top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) + .padding(horizontal = screenWidth * 0.02f), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterRow( + showFilterButtons = uiState.showFilterButtons, + toggleFilterButtons = { + setUiState( + uiState.copy(showFilterButtons = !uiState.showFilterButtons)) + }, + listOfButtons = listOfButtons, + modifier = Modifier.padding(bottom = screenHeight * 0.01f), + screenWidth = screenWidth, + screenHeight = screenHeight) + } + } + } + ProfileResults( + profiles = filteredWorkerProfiles, + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + onBookClick = { selectedProfile, loc, profile, banner -> + onBookClick(selectedProfile, loc, profile, banner) + }, + profileImagesMap = profileImagesMap, + bannerImagesMap = bannerImagesMap, + baseLocation = baseLocation, + screenHeight = screenHeight) } - }}, + } + }, modifier = Modifier.pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) @@ -368,4 +389,4 @@ fun SearchOnBoarding( end = lastAppliedMaxDist) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index ee75c76b..7c13a4cd 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -40,13 +40,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arygm.quickfix.MainActivity -import com.arygm.quickfix.R import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.locations.Location import com.arygm.quickfix.model.offline.small.PreferencesViewModel @@ -76,161 +74,155 @@ fun SearchWorkerResult( preferencesViewModel: PreferencesViewModel, workerViewModel: ProfileViewModel, ) { - val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } - var isWindowVisible by remember { mutableStateOf(false) } - val context = LocalContext.current - val locationHelper = LocationHelper(context, MainActivity()) - var selectedWorkerProfile by remember { mutableStateOf(WorkerProfile()) } - val filterState = rememberSearchFiltersState() - var baseLocation by remember { mutableStateOf(filterState.phoneLocation) } - var userProfile by remember { mutableStateOf(null) } - var uid by remember { mutableStateOf("Loading...") } - val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() + val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } + var isWindowVisible by remember { mutableStateOf(false) } + val context = LocalContext.current + val locationHelper = LocationHelper(context, MainActivity()) + var selectedWorkerProfile by remember { mutableStateOf(WorkerProfile()) } + val filterState = rememberSearchFiltersState() + val defaultBitmap = + Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) // Example default Bitmap - // Fetch user and set base location + var profilePicture by remember { mutableStateOf(defaultBitmap) } + var bannerPicture by remember { mutableStateOf(defaultBitmap) } + var baseLocation by remember { mutableStateOf(filterState.phoneLocation) } + var userProfile by remember { mutableStateOf(null) } + var uid by remember { mutableStateOf("Loading...") } + val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() - var loading by remember { mutableStateOf(true) } // Tracks if data is loading + // Fetch user and set base location - LaunchedEffect(Unit) { - if (locationHelper.checkPermissions()) { - locationHelper.getCurrentLocation { location -> - if (location != null) { - val userLoc = Location(location.latitude, location.longitude, "Phone Location") - filterState.phoneLocation = userLoc - } else { - Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() - } - } + var loading by remember { mutableStateOf(true) } // Tracks if data is loading + + LaunchedEffect(Unit) { + if (locationHelper.checkPermissions()) { + locationHelper.getCurrentLocation { location -> + if (location != null) { + val userLoc = Location(location.latitude, location.longitude, "Phone Location") + filterState.phoneLocation = userLoc } else { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() - } - uid = loadUserId(preferencesViewModel) - userProfileViewModel.fetchUserProfile(uid) { profile -> - userProfile = profile as UserProfile + Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() } + } + } else { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() } + uid = loadUserId(preferencesViewModel) + userProfileViewModel.fetchUserProfile(uid) { profile -> userProfile = profile as UserProfile } + } - val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() - var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } - val searchCategory by searchViewModel.searchCategory.collectAsState() + val workerProfiles by searchViewModel.subCategoryWorkerProfiles.collectAsState() + var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } + val searchCategory by searchViewModel.searchCategory.collectAsState() - var locationFilterApplied by remember { mutableStateOf(false) } - var selectedLocation by remember { mutableStateOf(Location()) } - var maxDistance by remember { mutableIntStateOf(0) } - var selectedLocationIndex by remember { mutableStateOf(null) } - var bannerImage by remember { mutableStateOf(R.drawable.moroccan_flag) } - var profilePicture by remember { mutableStateOf(R.drawable.placeholder_worker) } - var initialSaved by remember { mutableStateOf(false) } - var workerAddress by remember { mutableStateOf("") } + var locationFilterApplied by remember { mutableStateOf(false) } + var selectedLocation by remember { mutableStateOf(Location()) } + var maxDistance by remember { mutableIntStateOf(0) } + var selectedLocationIndex by remember { mutableStateOf(null) } + var initialSaved by remember { mutableStateOf(false) } + var selectedCityName by remember { mutableStateOf(null) } + var workerAddress by remember { mutableStateOf("") } - var lastAppliedMaxDist by remember { mutableIntStateOf(200) } + var lastAppliedMaxDist by remember { mutableIntStateOf(200) } - val listState = rememberLazyListState() - fun updateFilteredProfiles() { - filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) - } + val listState = rememberLazyListState() + fun updateFilteredProfiles() { + filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) + } - val listOfButtons = - filterState.getFilterButtons( - workerProfiles = workerProfiles, - filteredProfiles = filteredWorkerProfiles, - searchViewModel = searchViewModel, - onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, - onShowAvailabilityBottomSheet = { - setUiState(uiState.copy(showAvailabilityBottomSheet = true)) - }, - onShowServicesBottomSheet = { setUiState(uiState.copy(showServicesBottomSheet = true)) }, - onShowPriceRangeBottomSheet = { - setUiState(uiState.copy(showPriceRangeBottomSheet = true)) - }, - onShowLocationBottomSheet = { setUiState(uiState.copy(showLocationBottomSheet = true)) }) - // Wrap everything in a Box to allow overlay - val profileImagesMap by remember { mutableStateOf(mutableMapOf()) } - val bannerImagesMap by remember { mutableStateOf(mutableMapOf()) } + val listOfButtons = + filterState.getFilterButtons( + workerProfiles = workerProfiles, + filteredProfiles = filteredWorkerProfiles, + searchViewModel = searchViewModel, + onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, + onShowAvailabilityBottomSheet = { + setUiState(uiState.copy(showAvailabilityBottomSheet = true)) + }, + onShowServicesBottomSheet = { setUiState(uiState.copy(showServicesBottomSheet = true)) }, + onShowPriceRangeBottomSheet = { + setUiState(uiState.copy(showPriceRangeBottomSheet = true)) + }, + onShowLocationBottomSheet = { setUiState(uiState.copy(showLocationBottomSheet = true)) }) + // Wrap everything in a Box to allow overlay + val profileImagesMap by remember { mutableStateOf(mutableMapOf()) } + val bannerImagesMap by remember { mutableStateOf(mutableMapOf()) } - // Check if all required data is fetched - LaunchedEffect(workerProfiles) { - if (workerProfiles.isNotEmpty()) { - workerProfiles.forEach { profile -> - // Fetch profile images - workerViewModel.fetchProfileImageAsBitmap( - profile.uid, - onSuccess = { bitmap -> - profileImagesMap[profile.uid] = bitmap - checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { - loading = false - } - }, - onFailure = { Log.e("ProfileResults", "Failed to fetch profile image") }) + // Check if all required data is fetched + LaunchedEffect(workerProfiles) { + if (workerProfiles.isNotEmpty()) { + workerProfiles.forEach { profile -> + // Fetch profile images + workerViewModel.fetchProfileImageAsBitmap( + profile.uid, + onSuccess = { bitmap -> + profileImagesMap[profile.uid] = bitmap + checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { + loading = false + } + }, + onFailure = { Log.e("ProfileResults", "Failed to fetch profile image") }) - // Fetch banner images - workerViewModel.fetchBannerImageAsBitmap( - profile.uid, - onSuccess = { bitmap -> - bannerImagesMap[profile.uid] = bitmap - checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { - loading = false - } - }, - onFailure = { Log.e("ProfileResults", "Failed to fetch banner image") }) - } - } else { - loading = false // No profiles to load - } + // Fetch banner images + workerViewModel.fetchBannerImageAsBitmap( + profile.uid, + onSuccess = { bitmap -> + bannerImagesMap[profile.uid] = bitmap + checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { + loading = false + } + }, + onFailure = { Log.e("ProfileResults", "Failed to fetch banner image") }) + } + } else { + loading = false // No profiles to load } - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val screenHeight = maxHeight - val screenWidth = maxWidth + } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenHeight = maxHeight + val screenWidth = maxWidth - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { - Text(text = "Search Results", style = MaterialTheme.typography.titleMedium) - }, - navigationIcon = { - IconButton(onClick = { navigationActions.goBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - actions = { - IconButton(onClick = { /* Handle search */ }) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = colorScheme.onBackground - ) - } - }, - colors = - TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = colorScheme.surface - ), - ) - }) { paddingValues -> - // Main content inside the Scaffold - if (loading) { - // Display a loader - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - color = colorScheme.primary, modifier = Modifier.size(64.dp) - ) + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text(text = "Search Results", style = MaterialTheme.typography.titleMedium) + }, + navigationIcon = { + IconButton(onClick = { navigationActions.goBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back") } - } else { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top - ) { + }, + actions = { + IconButton(onClick = { /* Handle search */}) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = colorScheme.onBackground) + } + }, + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = colorScheme.surface), + ) + }) { paddingValues -> + // Main content inside the Scaffold + if (loading) { + // Display a loader + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = colorScheme.primary, modifier = Modifier.size(64.dp)) + } + } else { + Column( + modifier = Modifier.fillMaxWidth().padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top) { Text( text = searchSubcategory?.name ?: "Unknown", style = poppinsTypography.labelMedium, @@ -246,173 +238,163 @@ fun SearchWorkerResult( color = colorScheme.onSurface, textAlign = TextAlign.Center, ) - } + } - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) - .padding(horizontal = screenWidth * 0.02f) - .wrapContentHeight() - .background(colorScheme.surface), - verticalAlignment = Alignment.CenterVertically, - ) { - FilterRow( - showFilterButtons = uiState.showFilterButtons, - toggleFilterButtons = { - setUiState(uiState.copy(showFilterButtons = !uiState.showFilterButtons)) - }, - listOfButtons = listOfButtons, - modifier = Modifier.padding(bottom = screenHeight * 0.01f), - screenWidth = screenWidth, - screenHeight = screenHeight - ) - } - ProfileResults( - profiles = filteredWorkerProfiles, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - searchViewModel = searchViewModel, - accountViewModel = accountViewModel, - listState = listState, - onBookClick = { selectedProfile, locName -> - bannerImage = R.drawable.moroccan_flag - profilePicture = R.drawable.placeholder_worker - initialSaved = false - workerAddress = locName - isWindowVisible = true - selectedWorkerProfile = selectedProfile - }, workerViewModel = workerViewModel - ) + Row( + modifier = + Modifier.fillMaxWidth() + .padding(top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) + .padding(horizontal = screenWidth * 0.02f) + .wrapContentHeight() + .background(colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterRow( + showFilterButtons = uiState.showFilterButtons, + toggleFilterButtons = { + setUiState(uiState.copy(showFilterButtons = !uiState.showFilterButtons)) + }, + listOfButtons = listOfButtons, + modifier = Modifier.padding(bottom = screenHeight * 0.01f), + screenWidth = screenWidth, + screenHeight = screenHeight) + } + ProfileResults( + profiles = filteredWorkerProfiles, + modifier = Modifier.fillMaxWidth().weight(1f), + searchViewModel = searchViewModel, + accountViewModel = accountViewModel, + onBookClick = { selectedProfile, locName, profile, banner -> + bannerPicture = banner + profilePicture = profile + initialSaved = false + selectedCityName = locName + isWindowVisible = true + selectedWorkerProfile = selectedProfile + }, + profileImagesMap = profileImagesMap, + bannerImagesMap = bannerImagesMap, + baseLocation = baseLocation, + screenHeight = screenHeight, + ) } - } + } } - QuickFixAvailabilityBottomSheet( - uiState.showAvailabilityBottomSheet, - onDismissRequest = { setUiState(uiState.copy(showAvailabilityBottomSheet = false)) }, - onOkClick = { days, hour, minute -> - filterState.selectedDays = days - filterState.selectedHour = hour - filterState.selectedMinute = minute - filterState.availabilityFilterApplied = true - updateFilteredProfiles() - }, - onClearClick = { - filterState.availabilityFilterApplied = false - filterState.selectedDays = emptyList() - filterState.selectedHour = 0 - filterState.selectedMinute = 0 - updateFilteredProfiles() - }, - clearEnabled = filterState.availabilityFilterApplied - ) + QuickFixAvailabilityBottomSheet( + uiState.showAvailabilityBottomSheet, + onDismissRequest = { setUiState(uiState.copy(showAvailabilityBottomSheet = false)) }, + onOkClick = { days, hour, minute -> + filterState.selectedDays = days + filterState.selectedHour = hour + filterState.selectedMinute = minute + filterState.availabilityFilterApplied = true + updateFilteredProfiles() + }, + onClearClick = { + filterState.availabilityFilterApplied = false + filterState.selectedDays = emptyList() + filterState.selectedHour = 0 + filterState.selectedMinute = 0 + updateFilteredProfiles() + }, + clearEnabled = filterState.availabilityFilterApplied) - searchSubcategory?.let { - ChooseServiceTypeSheet( - uiState.showServicesBottomSheet, - it.tags, - selectedServices = filterState.selectedServices, - onApplyClick = { services -> - filterState.selectedServices = services - filterState.servicesFilterApplied = true - updateFilteredProfiles() - }, - onDismissRequest = { setUiState(uiState.copy(showServicesBottomSheet = false)) }, - onClearClick = { - filterState.selectedServices = emptyList() - filterState.servicesFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = filterState.servicesFilterApplied - ) - } + searchSubcategory?.let { + ChooseServiceTypeSheet( + uiState.showServicesBottomSheet, + it.tags, + selectedServices = filterState.selectedServices, + onApplyClick = { services -> + filterState.selectedServices = services + filterState.servicesFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { setUiState(uiState.copy(showServicesBottomSheet = false)) }, + onClearClick = { + filterState.selectedServices = emptyList() + filterState.servicesFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.servicesFilterApplied) + } - QuickFixPriceRangeBottomSheet( - uiState.showPriceRangeBottomSheet, - onApplyClick = { start, end -> - filterState.selectedPriceStart = start - filterState.selectedPriceEnd = end - filterState.priceFilterApplied = true - updateFilteredProfiles() - }, - onDismissRequest = { setUiState(uiState.copy(showPriceRangeBottomSheet = false)) }, - onClearClick = { - filterState.selectedPriceStart = 0 - filterState.selectedPriceEnd = 0 - filterState.priceFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = filterState.priceFilterApplied - ) + QuickFixPriceRangeBottomSheet( + uiState.showPriceRangeBottomSheet, + onApplyClick = { start, end -> + filterState.selectedPriceStart = start + filterState.selectedPriceEnd = end + filterState.priceFilterApplied = true + updateFilteredProfiles() + }, + onDismissRequest = { setUiState(uiState.copy(showPriceRangeBottomSheet = false)) }, + onClearClick = { + filterState.selectedPriceStart = 0 + filterState.selectedPriceEnd = 0 + filterState.priceFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = filterState.priceFilterApplied) - userProfile?.let { - QuickFixLocationFilterBottomSheet( - uiState.showLocationBottomSheet, - profile = it, - phoneLocation = filterState.phoneLocation, - selectedLocationIndex = selectedLocationIndex, - onApplyClick = { location, max -> - selectedLocation = location - lastAppliedMaxDist = max - baseLocation = location - maxDistance = max - selectedLocationIndex = it.locations.indexOf(location) + 1 + userProfile?.let { + QuickFixLocationFilterBottomSheet( + uiState.showLocationBottomSheet, + profile = it, + phoneLocation = filterState.phoneLocation, + selectedLocationIndex = selectedLocationIndex, + onApplyClick = { location, max -> + selectedLocation = location + lastAppliedMaxDist = max + baseLocation = location + maxDistance = max + selectedLocationIndex = it.locations.indexOf(location) + 1 - if (location == Location(0.0, 0.0, "Default")) { - Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT) - .show() - } - if (locationFilterApplied) { - updateFilteredProfiles() - } else { - filteredWorkerProfiles = - searchViewModel.filterWorkersByDistance( - filteredWorkerProfiles, - location, - max - ) - } - locationFilterApplied = true - }, - onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, - onClearClick = { - baseLocation = filterState.phoneLocation - lastAppliedMaxDist = 200 - selectedLocation = Location() - maxDistance = 0 - selectedLocationIndex = null - locationFilterApplied = false - updateFilteredProfiles() - }, - clearEnabled = locationFilterApplied, - end = lastAppliedMaxDist - ) - } - QuickFixSlidingWindowWorker( - isVisible = isWindowVisible, - onDismiss = { isWindowVisible = false }, - screenHeight = maxHeight, - screenWidth = maxWidth, - onContinueClick = { - quickFixViewModel.setSelectedWorkerProfile(selectedWorkerProfile) - navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) - }, - bannerImage = bannerImage, - profilePicture = profilePicture, - initialSaved = initialSaved, - workerCategory = selectedWorkerProfile.fieldOfWork, - workerAddress = workerAddress, - description = selectedWorkerProfile.description, - includedServices = selectedWorkerProfile.includedServices.map { it.name }, - addonServices = selectedWorkerProfile.addOnServices.map { it.name }, - workerRating = selectedWorkerProfile.reviews.map { it1 -> it1.rating }.average(), - tags = selectedWorkerProfile.tags, - reviews = selectedWorkerProfile.reviews.map { it.review }, - ) + if (location == Location(0.0, 0.0, "Default")) { + Toast.makeText(context, "Enable Location In Settings", Toast.LENGTH_SHORT).show() + } + if (locationFilterApplied) { + updateFilteredProfiles() + } else { + filteredWorkerProfiles = + searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) + } + locationFilterApplied = true + }, + onDismissRequest = { setUiState(uiState.copy(showLocationBottomSheet = false)) }, + onClearClick = { + baseLocation = filterState.phoneLocation + lastAppliedMaxDist = 200 + selectedLocation = Location() + maxDistance = 0 + selectedLocationIndex = null + locationFilterApplied = false + updateFilteredProfiles() + }, + clearEnabled = locationFilterApplied, + end = lastAppliedMaxDist) } + QuickFixSlidingWindowWorker( + isVisible = isWindowVisible, + onDismiss = { isWindowVisible = false }, + screenHeight = maxHeight, + screenWidth = maxWidth, + onContinueClick = { + quickFixViewModel.setSelectedWorkerProfile(selectedWorkerProfile) + navigationActions.navigateTo(UserScreen.QUICKFIX_ONBOARDING) + }, + bannerImage = bannerPicture, + profilePicture = profilePicture, + initialSaved = initialSaved, + workerCategory = selectedWorkerProfile.fieldOfWork, + selectedCityName = selectedCityName, + description = selectedWorkerProfile.description, + includedServices = selectedWorkerProfile.includedServices.map { it.name }, + addonServices = selectedWorkerProfile.addOnServices.map { it.name }, + workerRating = selectedWorkerProfile.reviews.map { it1 -> it1.rating }.average(), + tags = selectedWorkerProfile.tags, + reviews = selectedWorkerProfile.reviews.map { it.review }, + ) + } } fun checkIfLoadingComplete( @@ -421,9 +403,9 @@ fun checkIfLoadingComplete( bannerImages: Map, onComplete: () -> Unit ) { - val allProfilesLoaded = - profiles.all { profile -> - profileImages[profile.uid] != null && bannerImages[profile.uid] != null - } - if (allProfilesLoaded) onComplete() -} \ No newline at end of file + val allProfilesLoaded = + profiles.all { profile -> + profileImages[profile.uid] != null && bannerImages[profile.uid] != null + } + if (allProfilesLoaded) onComplete() +} From f6ff04a6a0ea4ad662dccea4483f9ece551f55ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Paul=20Lh=C3=B4te?= Date: Fri, 20 Dec 2024 06:58:46 +0100 Subject: [PATCH 15/15] Fix: implemented requested changes in PR review --- .../quickfix/ui/elements/defaultBitmap.kt | 5 ++ .../userModeUI/search/QuickFixFinder.kt | 17 +++--- .../userModeUI/search/SearchOnBoarding.kt | 55 ++++++++++--------- .../userModeUI/search/SearchWorkerResult.kt | 4 +- 4 files changed, 43 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/arygm/quickfix/ui/elements/defaultBitmap.kt diff --git a/app/src/main/java/com/arygm/quickfix/ui/elements/defaultBitmap.kt b/app/src/main/java/com/arygm/quickfix/ui/elements/defaultBitmap.kt new file mode 100644 index 00000000..c272af89 --- /dev/null +++ b/app/src/main/java/com/arygm/quickfix/ui/elements/defaultBitmap.kt @@ -0,0 +1,5 @@ +package com.arygm.quickfix.ui.elements + +import android.graphics.Bitmap + +val defaultBitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt index 745c7549..fe2d647b 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/QuickFixFinder.kt @@ -1,6 +1,5 @@ package com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.search -import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -35,7 +34,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.arygm.quickfix.model.account.AccountViewModel import com.arygm.quickfix.model.category.CategoryViewModel @@ -45,6 +43,7 @@ import com.arygm.quickfix.model.profile.WorkerProfile import com.arygm.quickfix.model.quickfix.QuickFixViewModel import com.arygm.quickfix.model.search.AnnouncementViewModel import com.arygm.quickfix.model.search.SearchViewModel +import com.arygm.quickfix.ui.elements.defaultBitmap import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScreen import kotlinx.coroutines.CoroutineScope @@ -74,8 +73,6 @@ fun QuickFixFinderScreen( val colorBackground = if (pagerState.currentPage == 0) colorScheme.background else colorScheme.surface val colorButton = if (pagerState.currentPage == 1) colorScheme.background else colorScheme.surface - val defaultBitmap = - Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) // Example default Bitmap var profilePicture by remember { mutableStateOf(defaultBitmap) } var bannerPicture by remember { mutableStateOf(defaultBitmap) } @@ -94,20 +91,22 @@ fun QuickFixFinderScreen( Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxSize().padding(end = 20.dp)) { + modifier = Modifier.fillMaxSize().padding(end = screenWidth * 0.05f)) { Surface( color = colorButton, - shape = RoundedCornerShape(20.dp), + shape = RoundedCornerShape(screenWidth * 0.05f), modifier = - Modifier.padding(horizontal = 40.dp) - .clip(RoundedCornerShape(20.dp))) { + Modifier.padding(horizontal = screenWidth * 0.1f) + .clip(RoundedCornerShape(screenWidth * 0.05f))) { TabRow( selectedTabIndex = pagerState.currentPage, containerColor = Color.Transparent, divider = {}, indicator = {}, modifier = - Modifier.padding(horizontal = 1.dp, vertical = 1.dp) + Modifier.padding( + horizontal = screenWidth * 0.01f, + vertical = screenWidth * 0.01f) .testTag("quickFixSearchTabRow")) { QuickFixScreenTab( pagerState, coroutineScope, 0, "Search", screenWidth) diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt index ea897672..075351bd 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchOnBoarding.kt @@ -76,8 +76,6 @@ fun SearchOnBoarding( locationHelper: LocationHelper = LocationHelper(LocalContext.current, MainActivity()) ) { val (uiState, setUiState) = remember { mutableStateOf(SearchUIState()) } - val workerProfiles by searchViewModel.workerProfilesSuggestions.collectAsState() - var filteredWorkerProfiles by remember { mutableStateOf(workerProfiles) } val context = LocalContext.current val searchSubcategory by searchViewModel.searchSubcategory.collectAsState() var locationFilterApplied by remember { mutableStateOf(false) } @@ -121,16 +119,16 @@ fun SearchOnBoarding( userProfileViewModel.fetchUserProfile(uid) { profile -> userProfile = profile as UserProfile } } fun updateFilteredProfiles() { - filteredWorkerProfiles = filterState.reapplyFilters(workerProfiles, searchViewModel) + searchedWorkers = filterState.reapplyFilters(profiles as List, searchViewModel) } // Build filter buttons val listOfButtons = filterState.getFilterButtons( - workerProfiles = workerProfiles, - filteredProfiles = filteredWorkerProfiles, + workerProfiles = profiles as List, + filteredProfiles = searchedWorkers, searchViewModel = searchViewModel, - onProfilesUpdated = { updated -> filteredWorkerProfiles = updated }, + onProfilesUpdated = { updated -> searchedWorkers = updated }, onShowAvailabilityBottomSheet = { setUiState(uiState.copy(showAvailabilityBottomSheet = true)) }, @@ -144,17 +142,18 @@ fun SearchOnBoarding( val bannerImagesMap by remember { mutableStateOf(mutableMapOf()) } var loading by remember { mutableStateOf(true) } // Tracks if data is loading - LaunchedEffect(workerProfiles) { - if (workerProfiles.isNotEmpty()) { - workerProfiles.forEach { profile -> + LaunchedEffect(profiles) { + if (profiles.isNotEmpty()) { + searchedWorkers.forEach { profile -> // Fetch profile images workerViewModel.fetchProfileImageAsBitmap( profile.uid, onSuccess = { bitmap -> profileImagesMap[profile.uid] = bitmap - checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { - loading = false - } + checkIfLoadingComplete( + profiles as List, profileImagesMap, bannerImagesMap) { + loading = false + } }, onFailure = { Log.e("ProfileResults", "Failed to fetch profile image") }) @@ -163,9 +162,10 @@ fun SearchOnBoarding( profile.uid, onSuccess = { bitmap -> bannerImagesMap[profile.uid] = bitmap - checkIfLoadingComplete(workerProfiles, profileImagesMap, bannerImagesMap) { - loading = false - } + checkIfLoadingComplete( + profiles as List, profileImagesMap, bannerImagesMap) { + loading = false + } }, onFailure = { Log.e("ProfileResults", "Failed to fetch banner image") }) } @@ -177,8 +177,8 @@ fun SearchOnBoarding( val widthRatio = maxWidth.value / 411f val heightRatio = maxHeight.value / 860f val sizeRatio = minOf(widthRatio, heightRatio) - val screenHeight = maxHeight - val screenWidth = maxWidth + val screenHeight = maxHeight.value + val screenWidth = maxWidth.value Scaffold( containerColor = colorScheme.background, @@ -221,8 +221,8 @@ fun SearchOnBoarding( textColor = colorScheme.onBackground, placeHolderColor = colorScheme.onBackground, leadIconColor = colorScheme.onBackground, - widthField = 300.dp * widthRatio, - heightField = 40.dp * heightRatio, + widthField = (screenWidth * 0.8).dp, + heightField = (screenHeight * 0.045).dp, moveContentHorizontal = 10.dp * widthRatio, moveContentBottom = 0.dp, moveContentTop = 0.dp, @@ -262,8 +262,9 @@ fun SearchOnBoarding( modifier = Modifier.fillMaxWidth() .padding( - top = screenHeight * 0.02f, bottom = screenHeight * 0.01f) - .padding(horizontal = screenWidth * 0.02f), + top = screenHeight.dp * 0.02f, + bottom = screenHeight.dp * 0.01f) + .padding(horizontal = screenWidth.dp * 0.02f), verticalAlignment = Alignment.CenterVertically, ) { FilterRow( @@ -273,9 +274,9 @@ fun SearchOnBoarding( uiState.copy(showFilterButtons = !uiState.showFilterButtons)) }, listOfButtons = listOfButtons, - modifier = Modifier.padding(bottom = screenHeight * 0.01f), - screenWidth = screenWidth, - screenHeight = screenHeight) + modifier = Modifier.padding(bottom = screenHeight.dp * 0.01f), + screenWidth = screenWidth.dp, + screenHeight = screenHeight.dp) } } } @@ -289,7 +290,7 @@ fun SearchOnBoarding( profileImagesMap = profileImagesMap, bannerImagesMap = bannerImagesMap, baseLocation = baseLocation, - screenHeight = screenHeight) + screenHeight = screenHeight.dp) } } }, @@ -372,8 +373,8 @@ fun SearchOnBoarding( if (locationFilterApplied) { updateFilteredProfiles() } else { - filteredWorkerProfiles = - searchViewModel.filterWorkersByDistance(filteredWorkerProfiles, location, max) + searchedWorkers = + searchViewModel.filterWorkersByDistance(searchedWorkers, location, max) } locationFilterApplied = true }, diff --git a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt index 7c13a4cd..ff08eec8 100644 --- a/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt +++ b/app/src/main/java/com/arygm/quickfix/ui/uiMode/appContentUI/userModeUI/search/SearchWorkerResult.kt @@ -57,6 +57,7 @@ import com.arygm.quickfix.ui.elements.ChooseServiceTypeSheet import com.arygm.quickfix.ui.elements.QuickFixAvailabilityBottomSheet import com.arygm.quickfix.ui.elements.QuickFixLocationFilterBottomSheet import com.arygm.quickfix.ui.elements.QuickFixPriceRangeBottomSheet +import com.arygm.quickfix.ui.elements.defaultBitmap import com.arygm.quickfix.ui.navigation.NavigationActions import com.arygm.quickfix.ui.theme.poppinsTypography import com.arygm.quickfix.ui.uiMode.appContentUI.userModeUI.navigation.UserScreen @@ -80,8 +81,6 @@ fun SearchWorkerResult( val locationHelper = LocationHelper(context, MainActivity()) var selectedWorkerProfile by remember { mutableStateOf(WorkerProfile()) } val filterState = rememberSearchFiltersState() - val defaultBitmap = - Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) // Example default Bitmap var profilePicture by remember { mutableStateOf(defaultBitmap) } var bannerPicture by remember { mutableStateOf(defaultBitmap) } @@ -100,6 +99,7 @@ fun SearchWorkerResult( if (location != null) { val userLoc = Location(location.latitude, location.longitude, "Phone Location") filterState.phoneLocation = userLoc + baseLocation = userLoc } else { Toast.makeText(context, "Unable to fetch location", Toast.LENGTH_SHORT).show() }