Skip to content

Commit ef9eb95

Browse files
authored
Merge pull request #327 from bounswe/feat/mobile/discovery-multi-category-sort
2 parents f92c5a3 + f9863ad commit ef9eb95

4 files changed

Lines changed: 169 additions & 94 deletions

File tree

mobile/app/src/main/java/com/bounswe/group9/mobile/ui/discovery/DiscoveryScreen.kt

Lines changed: 110 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,8 @@ fun DiscoveryScreen(
253253
uiState = uiState,
254254
isLoggedIn = token != null,
255255
onQuickFilterSelect = viewModel::onQuickFilterSelected,
256-
onCategorySelect = viewModel::onCategorySelected,
256+
onCategoryToggle = viewModel::onCategoryToggled,
257+
onSortSelect = viewModel::onSortSelected,
257258
onBookmarkedToggle = viewModel::onBookmarkedOnlyToggle,
258259
onGoingToggle = viewModel::onGoingOnlyToggle,
259260
onWheelchairToggle = viewModel::onWheelchairToggle,
@@ -510,7 +511,7 @@ private fun FilterSheetContent(
510511
uiState: DiscoveryUiState,
511512
isLoggedIn: Boolean,
512513
onQuickFilterSelect: (String?) -> Unit,
513-
onCategorySelect: (String?) -> Unit,
514+
onCategoryToggle: (String) -> Unit,
514515
onBookmarkedToggle: () -> Unit,
515516
onGoingToggle: () -> Unit,
516517
onWheelchairToggle: () -> Unit,
@@ -521,12 +522,13 @@ private fun FilterSheetContent(
521522
onQuietFriendlyToggle: () -> Unit,
522523
onSuggestedToggle: () -> Unit,
523524
onProximitySortToggle: () -> Unit,
525+
onSortSelect: (String?) -> Unit,
524526
onClear: () -> Unit,
525527
onApply: () -> Unit
526528
) {
527-
val hasAnyFilter = uiState.selectedQuickFilter != null || uiState.selectedCategoryId != null ||
529+
val hasAnyFilter = uiState.selectedQuickFilter != null || uiState.selectedCategoryIds.isNotEmpty() ||
528530
uiState.bookmarkedOnly || uiState.goingOnly || uiState.hasAccessibilityFilter ||
529-
uiState.proximitySort || uiState.suggestedActive
531+
uiState.proximitySort || uiState.suggestedActive || uiState.selectedSort != null
530532

531533
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)) {
532534
// Header
@@ -552,88 +554,23 @@ private fun FilterSheetContent(
552554
.padding(horizontal = 20.dp, vertical = 16.dp),
553555
verticalArrangement = Arrangement.spacedBy(20.dp)
554556
) {
555-
// ── Quick Filters ──────────────────────────────────────────────────
556-
FilterSectionLabel("QUICK FILTERS")
557-
Row(
558-
modifier = Modifier.horizontalScroll(rememberScrollState()),
559-
horizontalArrangement = Arrangement.spacedBy(8.dp)
560-
) {
561-
listOf(
562-
"now" to "Now",
563-
"today" to "Today",
564-
"weekend" to "Weekend",
565-
"this_week" to "This Week",
566-
"upcoming" to "Upcoming",
567-
"past" to "Past"
568-
).forEach { (key, label) ->
569-
FilterPill(label = label, selected = uiState.selectedQuickFilter == key) {
570-
onQuickFilterSelect(key)
571-
}
572-
}
573-
if (isLoggedIn) {
574-
FilterPill("✨ Suggested for you", uiState.suggestedActive, onSuggestedToggle)
575-
FilterPill("🔖 Bookmarked", uiState.bookmarkedOnly, onBookmarkedToggle)
576-
FilterPill("✓ Going", uiState.goingOnly, onGoingToggle)
577-
}
578-
}
579-
557+
FilterQuickFiltersSection(uiState, isLoggedIn, onQuickFilterSelect,
558+
onBookmarkedToggle, onGoingToggle, onSuggestedToggle)
559+
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
560+
FilterSortSection(uiState, onSortSelect)
580561
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
581-
582-
// ── Ranking & Proximity ────────────────────────────────────────────
583562
FilterSectionLabel("RANKING")
584563
FilterSwitchRow(
585564
label = "Proximity Ranking",
586565
sublabel = "Sort by distance from your current location",
587566
checked = uiState.proximitySort,
588567
onCheckedChange = { onProximitySortToggle() }
589568
)
590-
591569
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
592-
593-
// ── Accessibility ──────────────────────────────────────────────────
594-
FilterSectionLabel("ACCESSIBILITY")
595-
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
596-
FilterSwitchRow("Wheelchair Access", checked = uiState.wheelchair,
597-
onCheckedChange = { onWheelchairToggle() })
598-
FilterSwitchRow("Accessible Restroom", checked = uiState.accessibleRestroom,
599-
onCheckedChange = { onAccessibleRestroomToggle() })
600-
FilterSwitchRow("Elevator Available", checked = uiState.elevator,
601-
onCheckedChange = { onElevatorToggle() })
602-
FilterSwitchRow("Seating Available", checked = uiState.seating,
603-
onCheckedChange = { onSeatingToggle() })
604-
FilterSwitchRow("Captions Support", checked = uiState.captions,
605-
onCheckedChange = { onCaptionsToggle() })
606-
FilterSwitchRow("Quiet-Friendly", checked = uiState.quietFriendly,
607-
onCheckedChange = { onQuietFriendlyToggle() })
608-
}
609-
570+
FilterAccessibilitySection(uiState, onWheelchairToggle, onAccessibleRestroomToggle,
571+
onElevatorToggle, onSeatingToggle, onCaptionsToggle, onQuietFriendlyToggle)
610572
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
611-
612-
// ── Category ───────────────────────────────────────────────────────
613-
FilterSectionLabel("CATEGORY")
614-
uiState.categories.forEach { cat ->
615-
val selected = uiState.selectedCategoryId == cat.id
616-
Row(
617-
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))
618-
.clickable { onCategorySelect(cat.id) }
619-
.padding(horizontal = 10.dp, vertical = 10.dp),
620-
horizontalArrangement = Arrangement.SpaceBetween,
621-
verticalAlignment = Alignment.CenterVertically
622-
) {
623-
Text(cat.name, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
624-
Box(
625-
modifier = Modifier.size(20.dp).clip(RoundedCornerShape(4.dp))
626-
.background(
627-
if (selected) MaterialTheme.colorScheme.primary
628-
else MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
629-
),
630-
contentAlignment = Alignment.Center
631-
) {
632-
if (selected) Icon(Icons.Default.Check, null,
633-
tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(14.dp))
634-
}
635-
}
636-
}
573+
FilterCategorySection(uiState, onCategoryToggle)
637574
}
638575

639576
Button(
@@ -647,6 +584,103 @@ private fun FilterSheetContent(
647584
}
648585
}
649586

587+
@Composable
588+
private fun FilterQuickFiltersSection(
589+
uiState: DiscoveryUiState,
590+
isLoggedIn: Boolean,
591+
onQuickFilterSelect: (String?) -> Unit,
592+
onBookmarkedToggle: () -> Unit,
593+
onGoingToggle: () -> Unit,
594+
onSuggestedToggle: () -> Unit
595+
) {
596+
FilterSectionLabel("QUICK FILTERS")
597+
Row(
598+
modifier = Modifier.horizontalScroll(rememberScrollState()),
599+
horizontalArrangement = Arrangement.spacedBy(8.dp)
600+
) {
601+
if (isLoggedIn) FilterPill("✨ Suggested for you", uiState.suggestedActive, onSuggestedToggle)
602+
listOf("now" to "Now", "today" to "Today", "weekend" to "Weekend",
603+
"this_week" to "This Week", "upcoming" to "Upcoming", "past" to "Past"
604+
).forEach { (key, label) ->
605+
FilterPill(label = label, selected = uiState.selectedQuickFilter == key) {
606+
onQuickFilterSelect(key)
607+
}
608+
}
609+
if (isLoggedIn) {
610+
FilterPill("🔖 Bookmarked", uiState.bookmarkedOnly, onBookmarkedToggle)
611+
FilterPill("✓ Going", uiState.goingOnly, onGoingToggle)
612+
}
613+
}
614+
}
615+
616+
@Composable
617+
private fun FilterSortSection(uiState: DiscoveryUiState, onSortSelect: (String?) -> Unit) {
618+
FilterSectionLabel("SORT BY")
619+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
620+
listOf("start_time" to "Start Time", "category" to "Category").forEach { (key, label) ->
621+
FilterPill(
622+
label = label,
623+
selected = uiState.selectedSort == key && !uiState.proximitySort
624+
) { onSortSelect(if (uiState.selectedSort == key) null else key) }
625+
}
626+
}
627+
}
628+
629+
@Composable
630+
private fun FilterAccessibilitySection(
631+
uiState: DiscoveryUiState,
632+
onWheelchairToggle: () -> Unit,
633+
onAccessibleRestroomToggle: () -> Unit,
634+
onElevatorToggle: () -> Unit,
635+
onSeatingToggle: () -> Unit,
636+
onCaptionsToggle: () -> Unit,
637+
onQuietFriendlyToggle: () -> Unit
638+
) {
639+
FilterSectionLabel("ACCESSIBILITY")
640+
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
641+
FilterSwitchRow("Wheelchair Access", checked = uiState.wheelchair,
642+
onCheckedChange = { onWheelchairToggle() })
643+
FilterSwitchRow("Accessible Restroom", checked = uiState.accessibleRestroom,
644+
onCheckedChange = { onAccessibleRestroomToggle() })
645+
FilterSwitchRow("Elevator Available", checked = uiState.elevator,
646+
onCheckedChange = { onElevatorToggle() })
647+
FilterSwitchRow("Seating Available", checked = uiState.seating,
648+
onCheckedChange = { onSeatingToggle() })
649+
FilterSwitchRow("Captions Support", checked = uiState.captions,
650+
onCheckedChange = { onCaptionsToggle() })
651+
FilterSwitchRow("Quiet-Friendly", checked = uiState.quietFriendly,
652+
onCheckedChange = { onQuietFriendlyToggle() })
653+
}
654+
}
655+
656+
@Composable
657+
private fun FilterCategorySection(uiState: DiscoveryUiState, onCategoryToggle: (String) -> Unit) {
658+
FilterSectionLabel("CATEGORY")
659+
uiState.categories.forEach { cat ->
660+
val selected = cat.id in uiState.selectedCategoryIds
661+
Row(
662+
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))
663+
.clickable { onCategoryToggle(cat.id) }
664+
.padding(horizontal = 10.dp, vertical = 10.dp),
665+
horizontalArrangement = Arrangement.SpaceBetween,
666+
verticalAlignment = Alignment.CenterVertically
667+
) {
668+
Text(cat.name, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
669+
Box(
670+
modifier = Modifier.size(20.dp).clip(RoundedCornerShape(4.dp))
671+
.background(
672+
if (selected) MaterialTheme.colorScheme.primary
673+
else MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
674+
),
675+
contentAlignment = Alignment.Center
676+
) {
677+
if (selected) Icon(Icons.Default.Check, null,
678+
tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(14.dp))
679+
}
680+
}
681+
}
682+
}
683+
650684
@Composable
651685
private fun FilterSectionLabel(text: String) {
652686
Text(

mobile/app/src/main/java/com/bounswe/group9/mobile/ui/discovery/DiscoveryUiState.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ data class DiscoveryUiState(
77
val events: List<EventListItemDto> = emptyList(),
88
val categories: List<CategoryDto> = emptyList(),
99
val search: String = "",
10-
val selectedCategoryId: String? = null,
10+
// Multi-select: empty = no filter, size == 1 → sent to API, size >= 2 → client-side filtered
11+
val selectedCategoryIds: Set<String> = emptySet(),
1112
// quick_filter: "now"|"today"|"weekend"|"upcoming"|"this_week"|"past"|null
1213
val selectedQuickFilter: String? = null,
1314
val bookmarkedOnly: Boolean = false,
@@ -19,6 +20,9 @@ data class DiscoveryUiState(
1920
val seating: Boolean = false,
2021
val captions: Boolean = false,
2122
val quietFriendly: Boolean = false,
23+
// Explicit sort: "start_time" | "category" | null (null = server default)
24+
// "distance" sort is handled separately via proximitySort + GPS coords below.
25+
val selectedSort: String? = null,
2226
// Proximity ranking: sort=distance using device GPS coords
2327
val proximitySort: Boolean = false,
2428
val nearLat: Double? = null,
@@ -39,9 +43,15 @@ data class DiscoveryUiState(
3943
val hasAccessibilityFilter: Boolean get() =
4044
wheelchair || accessibleRestroom || elevator || seating || captions || quietFriendly
4145

42-
/** Client-side filter for bookmarked/going (server handles everything else). */
46+
/**
47+
* Client-side filters applied on top of the server response:
48+
* - bookmarkedOnly / goingOnly (personal filters the API can't gate directly)
49+
* - multi-category (when 2+ selected the API returns unfiltered results so we filter here)
50+
*/
4351
val displayedEvents: List<EventListItemDto> get() = events.filter { event ->
4452
(!bookmarkedOnly || event.is_bookmarked == true) &&
45-
(!goingOnly || event.attendance_status == "going")
53+
(!goingOnly || event.attendance_status == "going") &&
54+
(selectedCategoryIds.size <= 1 ||
55+
event.categories.any { it.id in selectedCategoryIds })
4656
}
4757
}

mobile/app/src/main/java/com/bounswe/group9/mobile/ui/discovery/DiscoveryViewModel.kt

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,14 @@ class DiscoveryViewModel(
4242
}
4343
}
4444

45-
fun onCategorySelected(categoryId: String?) {
46-
val new = if (_uiState.value.selectedCategoryId == categoryId) null else categoryId
47-
_uiState.value = _uiState.value.copy(selectedCategoryId = new)
45+
fun onCategoryToggled(categoryId: String) {
46+
val current = _uiState.value.selectedCategoryIds
47+
val updated = if (categoryId in current) current - categoryId else current + categoryId
48+
_uiState.value = _uiState.value.copy(selectedCategoryIds = updated)
49+
}
50+
51+
fun onSortSelected(sort: String?) {
52+
_uiState.value = _uiState.value.copy(selectedSort = sort)
4853
}
4954

5055
fun onQuickFilterSelected(filter: String?) {
@@ -166,7 +171,7 @@ class DiscoveryViewModel(
166171

167172
fun clearFilters() {
168173
_uiState.value = _uiState.value.copy(
169-
selectedCategoryId = null,
174+
selectedCategoryIds = emptySet(),
170175
selectedQuickFilter = null,
171176
bookmarkedOnly = false,
172177
goingOnly = false,
@@ -176,6 +181,7 @@ class DiscoveryViewModel(
176181
seating = false,
177182
captions = false,
178183
quietFriendly = false,
184+
selectedSort = null,
179185
proximitySort = false,
180186
nearLat = null,
181187
nearLng = null,
@@ -190,7 +196,8 @@ class DiscoveryViewModel(
190196
val s = _uiState.value
191197
var count = 0
192198
if (s.selectedQuickFilter != null) count++
193-
if (s.selectedCategoryId != null) count++
199+
count += s.selectedCategoryIds.size
200+
if (s.selectedSort != null) count++
194201
if (s.bookmarkedOnly) count++
195202
if (s.goingOnly) count++
196203
if (s.wheelchair) count++
@@ -218,15 +225,22 @@ class DiscoveryViewModel(
218225
repository.getEvents(
219226
token = token,
220227
search = state.search.takeIf { it.isNotBlank() },
221-
categoryId = state.selectedCategoryId,
228+
// Pass categoryId only when exactly 1 category is selected.
229+
// With 2+ selections the API returns unfiltered results and
230+
// DiscoveryUiState.displayedEvents applies the client-side filter.
231+
categoryId = state.selectedCategoryIds.singleOrNull(),
222232
quickFilter = state.selectedQuickFilter,
223233
wheelchair = state.wheelchair.takeIf { it },
224234
accessibleRestroom = state.accessibleRestroom.takeIf { it },
225235
elevator = state.elevator.takeIf { it },
226236
seating = state.seating.takeIf { it },
227237
captions = state.captions.takeIf { it },
228238
quietFriendly = state.quietFriendly.takeIf { it },
229-
sort = if (state.proximitySort && state.nearLat != null) "distance" else null,
239+
// Proximity sort (GPS) takes precedence; otherwise use the explicit selection.
240+
sort = when {
241+
state.proximitySort && state.nearLat != null -> "distance"
242+
else -> state.selectedSort
243+
},
230244
nearLat = state.nearLat,
231245
nearLng = state.nearLng,
232246
// Suggested only goes to the wire when the chip is on AND the user is signed in.

0 commit comments

Comments
 (0)