From e78bda6d138e66ab9a868e10b90f6df4f325c408 Mon Sep 17 00:00:00 2001 From: Ravi Sahu Date: Thu, 16 Jul 2026 18:17:37 +0530 Subject: [PATCH 1/6] fix: stabilize discover/likes races and improve location UX Keep swiped cards out of Discover (session filter + no HTTP cache), merge optimistic likes/passes so the Likes tab does not flash-remove just-swiped items, and surface popular NCR cities offline while Places is loading. Also drop the floating like/pass action bar (gesture-only deck), tune Android Gradle memory for low-RAM builds, and clear pre-existing analyzer warnings that blocked pre-commit. --- android/app/build.gradle.kts | 4 +- android/app/src/main/AndroidManifest.xml | 7 +- android/gradle.properties | 16 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 2 +- lib/core/config/dev_env.g.dart | 1 - lib/core/controllers/page_data_loader.dart | 43 +++- lib/core/controllers/page_state_service.dart | 205 ++++++++++++++++-- lib/core/data/models/popular_city.dart | 105 +++++++++ lib/core/services/google_places_service.dart | 56 ++++- lib/core/translations/app_translations.dart | 4 - .../widgets/common/location_selector.dart | 59 ++++- .../widgets/property_swipe_card.dart | 8 +- .../widgets/property_swipe_stack.dart | 95 ++------ .../widgets/swipe_card_action_buttons.dart | 111 ---------- .../controllers/likes_controller.dart | 7 +- .../views/location_search_view.dart | 63 ++++-- .../controllers/page_data_loader_test.dart | 12 + .../controllers/page_filter_manager_test.dart | 2 +- .../controllers/page_state_service_test.dart | 93 +++++++- test/core/data/models/popular_city_test.dart | 69 ++++++ .../firebase_initializer_enabled_test.dart | 34 --- test/core/utils/error_handler_test.dart | 12 +- .../widgets/chat_message_bubble_test.dart | 11 +- .../auth/data/auth_repository_test.dart | 2 +- .../views/discover_view_test.dart | 11 +- .../widgets/property_swipe_stack_test.dart | 92 +------- .../presentation/views/explore_view_test.dart | 5 - .../views/location_search_view_test.dart | 29 ++- .../views/property_details_view_test.dart | 17 +- test/helpers/fake_webview_platform.dart | 1 - 31 files changed, 737 insertions(+), 441 deletions(-) create mode 100644 lib/core/data/models/popular_city.dart delete mode 100644 lib/features/discover/presentation/widgets/swipe_card_action_buttons.dart create mode 100644 test/core/data/models/popular_city_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ba81e842..e2bcd6c4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -34,8 +34,8 @@ android { namespace = "com.the360ghar.ghar360" // Explicitly target Android 15 / API 36 to meet plugin requirements. compileSdk = 36 - // maplibre_gl requires NDK 28.x; use highest required (backward compatible). - ndkVersion = "28.1.13356709" + // Align with Flutter/plugin highest requirement (jni wants 28.2.x; NDK is backward-compatible). + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_21 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e1b7da4a..e596917a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -114,12 +114,7 @@ android:name="flutterEmbedding" android:value="2" /> - - - - + diff --git a/android/gradle.properties b/android/gradle.properties index 4c71ad37..3191e87d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,18 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError --enable-native-access=ALL-UNNAMED +# Conservative memory for Android builds (avoids daemon OOM on 8GB hosts while +# still covering AsmClassesTransform / mergeDebugGlobalSynthetics heap needs). +# Raise -Xmx / workers.max on high-RAM CI if builds are CPU-bound. +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED + +# Prefer stability over max parallelism (large Flutter plugin graphs). +org.gradle.workers.max=1 +org.gradle.parallel=false +org.gradle.caching=true +org.gradle.daemon=true +org.gradle.configureondemand=false + +# Kotlin compiler daemon (separate process). +kotlin.daemon.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m + android.useAndroidX=true android.enableJetifier=true # This builtInKotlin flag was added automatically by Flutter migrator diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 02767eb1..e4ef43fb 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ad176341..394ff8da 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.12.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.0" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false // Google Services (Firebase) id("com.google.gms.google-services") version "4.4.2" apply false // Firebase Gradle plugins applied in app module diff --git a/lib/core/config/dev_env.g.dart b/lib/core/config/dev_env.g.dart index 092ea165..d3d931cc 100644 --- a/lib/core/config/dev_env.g.dart +++ b/lib/core/config/dev_env.g.dart @@ -11,4 +11,3 @@ const Map kDevEnv = { // // }; - diff --git a/lib/core/controllers/page_data_loader.dart b/lib/core/controllers/page_data_loader.dart index d3040d30..a73d2d4e 100644 --- a/lib/core/controllers/page_data_loader.dart +++ b/lib/core/controllers/page_data_loader.dart @@ -199,13 +199,22 @@ class PageDataLoader { cursor: cursor, limit: pageType == PageType.discover ? 20 : 50, excludeSwiped: pageType == PageType.discover, - useCache: true, + // Never cache discover pages โ€” swipes must not reappear from stale cache. + useCache: pageType != PageType.discover, ); - final newProperties = [...state.properties, ...response.items]; + final pageItems = pageType == PageType.discover + ? _pageState.filterOutSessionSwiped(response.items) + : response.items; + // Re-read after await so concurrent swipes aren't re-appended. + final latest = _pageState.getStateForPage(pageType); + final newProperties = [ + ...latest.properties, + ...pageItems.where((p) => !latest.properties.any((e) => e.id == p.id)), + ]; _pageState.updatePageState( pageType, - state.copyWith( + latest.copyWith( properties: newProperties, nextCursor: response.nextCursor, hasMore: response.hasMorePages, @@ -283,10 +292,15 @@ class PageDataLoader { isLiked: isLikedSegment, ); + // Re-read state after the await โ€” optimistic likes may have been added + // while the request was in flight. + final latest = _pageState.getStateForPage(pageType); + final merged = _pageState.mergeLikesServerResults(resp.items, isLikedSegment: isLikedSegment); + _pageState.updatePageState( pageType, - state.copyWith( - properties: resp.items, + latest.copyWith( + properties: merged, selectedLocation: loc, nextCursor: resp.nextCursor, hasMore: resp.hasMorePages, @@ -296,6 +310,10 @@ class PageDataLoader { error: null, ), ); + _pageState.syncLikesSegmentCacheFromVisible( + hasMore: resp.hasMorePages, + nextCursor: resp.nextCursor, + ); return; } @@ -308,17 +326,26 @@ class PageDataLoader { cursor: null, limit: pageType == PageType.discover ? 20 : 50, excludeSwiped: pageType == PageType.discover, - useCache: true, + // Discover must not use HTTP cache โ€” a stale page would re-show swiped cards. + useCache: pageType != PageType.discover, ); DebugLogger.debug( '๐Ÿ“ก [DATA_LOADER] Received ${resp.items.length} properties for ' '${pageType.name} (hasMore=${resp.hasMorePages}, ' 'nextCursor=${resp.nextCursor != null})', ); + + // Re-read after await: swipes during the request already removed ids from + // the local deck; also drop any session-swiped cards the API still returns. + final latest = _pageState.getStateForPage(pageType); + final items = pageType == PageType.discover + ? _pageState.filterOutSessionSwiped(resp.items) + : resp.items; + _pageState.updatePageState( pageType, - state.copyWith( - properties: resp.items, + latest.copyWith( + properties: items, selectedLocation: loc, nextCursor: resp.nextCursor, hasMore: resp.hasMorePages, diff --git a/lib/core/controllers/page_state_service.dart b/lib/core/controllers/page_state_service.dart index b4490987..c2ee04bf 100644 --- a/lib/core/controllers/page_state_service.dart +++ b/lib/core/controllers/page_state_service.dart @@ -130,6 +130,10 @@ class PageStateService extends GetxController { exploreState.value = PageStateModel.initial(PageType.explore); discoverState.value = PageStateModel.initial(PageType.discover); likesState.value = PageStateModel.initial(PageType.likes); + _likesSegmentCache.clear(); + _optimisticLiked.clear(); + _optimisticPassed.clear(); + _sessionSwipedPropertyIds.clear(); try { _storage.remove(_exploreStateStorageKey); @@ -538,15 +542,45 @@ class PageStateService extends GetxController { // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Future recordSwipe({required int propertyId, required bool isLiked}) async { - // Maintain likes list optimistically + // Maintain liked/passed segment caches AND the visible list so the Likes + // tab reflects Discover swipes immediately, regardless of which segment + // is currently selected. + final prop = _findPropertyInAnyList(propertyId); if (isLiked) { - final prop = _findPropertyInAnyList(propertyId); - if (prop != null) addPropertyToLikes(prop); + if (prop != null) { + _trackOptimisticLike(prop); + _upsertLikesSegmentCache('liked', prop); + if (currentLikesSegment == 'liked') { + _prependToVisibleLikesList(prop); + } + } + _removeFromLikesSegmentCache('passed', propertyId); + if (currentLikesSegment == 'passed') { + removePropertyFromLikes(propertyId); + } } else { - removePropertyFromLikes(propertyId); + // Pass: drop from liked, add to passed. + if (prop != null) { + _trackOptimisticPass(prop); + } else { + // Still drop any pending like for this id. + _optimisticLiked.remove(propertyId); + } + _removeFromLikesSegmentCache('liked', propertyId); + if (currentLikesSegment == 'liked') { + removePropertyFromLikes(propertyId); + } + if (prop != null) { + _upsertLikesSegmentCache('passed', prop); + if (currentLikesSegment == 'passed') { + _prependToVisibleLikesList(prop); + } + } } - // Also remove from discover deck optimistically + // Also remove from discover deck optimistically and remember the id so + // a later discover refresh (cache / server lag) cannot reintroduce it. + _sessionSwipedPropertyIds.add(propertyId); removePropertyFromDiscover(propertyId); // Network sync โ€” await so failures propagate to callers, which revert the @@ -573,10 +607,32 @@ class PageStateService extends GetxController { updatePageState(PageType.discover, state.copyWith(properties: updatedList)); } + /// Property ids swiped this session. Discover fetch results are filtered + /// against this set so cards do not reappear after tab switches / refresh + /// races before `exclude_swiped` is reflected server-side. + final Set _sessionSwipedPropertyIds = {}; + + /// Filters [items] to drop any property swiped earlier in this session. + List filterOutSessionSwiped(List items) { + if (_sessionSwipedPropertyIds.isEmpty || items.isEmpty) return items; + final filtered = items.where((p) => !_sessionSwipedPropertyIds.contains(p.id)).toList(); + final removed = items.length - filtered.length; + if (removed > 0) { + DebugLogger.debug( + '๐Ÿ‘† Filtered $removed session-swiped propert' + '${removed == 1 ? 'y' : 'ies'} from discover results', + ); + } + return filtered; + } + + bool isSessionSwiped(int propertyId) => _sessionSwipedPropertyIds.contains(propertyId); + /// Re-inserts a property at the front of the discover deck. Used by the /// undo-swipe flow to restore the previously-swiped property so the user /// sees it again as the top card. void reinsertPropertyToDiscover(PropertyModel property) { + _sessionSwipedPropertyIds.remove(property.id); final state = discoverState.value; final exists = state.properties.any((p) => p.id == property.id); if (exists) return; @@ -590,13 +646,21 @@ class PageStateService extends GetxController { /// reverses the likes list mutation from the original swipe and fires the /// background network sync with the opposite action. Future undoSwipe({required int propertyId, required bool originalIsLiked}) async { - // Reverse ONLY the likes list mutation that the original swipe made: - // - Original LIKE added the property to likes โ†’ undo removes it. - // - Original PASS did not touch likes (the property was in discover, - // not likes) โ†’ undo leaves likes unchanged. We do NOT add to likes - // because the user's intent is to re-swipe, not auto-like. + // Reverse ONLY the likes/passed mutations that the original swipe made: + // - Original LIKE added to liked โ†’ undo removes from liked (and its cache). + // - Original PASS added to passed โ†’ undo removes from passed cache only. if (originalIsLiked) { - removePropertyFromLikes(propertyId); + _optimisticLiked.remove(propertyId); + _removeFromLikesSegmentCache('liked', propertyId); + if (currentLikesSegment == 'liked') { + removePropertyFromLikes(propertyId); + } + } else { + _optimisticPassed.remove(propertyId); + _removeFromLikesSegmentCache('passed', propertyId); + if (currentLikesSegment == 'passed') { + removePropertyFromLikes(propertyId); + } } // Network sync with the REVERSED action. Without a delete-swipe API, @@ -606,29 +670,128 @@ class PageStateService extends GetxController { } void removePropertyFromLikes(int propertyId) { + // User explicitly removed from the visible segment โ€” drop pending too. + if (currentLikesSegment == 'liked') { + _optimisticLiked.remove(propertyId); + } else { + _optimisticPassed.remove(propertyId); + } final state = likesState.value; final updatedList = state.properties.where((p) => p.id != propertyId).toList(); updatePageState(PageType.likes, state.copyWith(properties: updatedList)); + // Keep whichever segment is currently loaded in sync with its cache. + _removeFromLikesSegmentCache(currentLikesSegment, propertyId); } void addPropertyToLikes(PropertyModel property) { + // Always keep the liked segment cache fresh so switching tabs / revisiting + // Likes shows the property even if the user was on "passed" when swiping. + _trackOptimisticLike(property); + _upsertLikesSegmentCache('liked', property); + _removeFromLikesSegmentCache('passed', property.id); if (currentLikesSegment != 'liked') return; - final state = likesState.value; - final exists = state.properties.any((p) => p.id == property.id); - if (!exists) { - final updatedList = [property, ...state.properties]; - updatePageState(PageType.likes, state.copyWith(properties: updatedList)); - } + _prependToVisibleLikesList(property); } void addPropertyToPassed(PropertyModel property) { + _trackOptimisticPass(property); + _upsertLikesSegmentCache('passed', property); + _removeFromLikesSegmentCache('liked', property.id); if (currentLikesSegment != 'passed') return; + _prependToVisibleLikesList(property); + } + + // โ”€โ”€ Optimistic likes/pass that survive server refresh races โ”€โ”€ + + /// Properties liked/passed locally but not yet returned by history API. + /// Prevents background refresh from flash-removing a just-swiped card. + final Map _optimisticLiked = {}; + final Map _optimisticPassed = {}; + + void _trackOptimisticLike(PropertyModel property) { + _optimisticLiked[property.id] = property; + _optimisticPassed.remove(property.id); + } + + void _trackOptimisticPass(PropertyModel property) { + _optimisticPassed[property.id] = property; + _optimisticLiked.remove(property.id); + } + + /// Merges a server history page with still-pending optimistic swipes. + /// + /// Pending items that the server now includes are cleared. Remaining + /// pending items are prepended so a racey refresh cannot wipe them. + List mergeLikesServerResults( + List serverItems, { + required bool isLikedSegment, + }) { + final optimistic = isLikedSegment ? _optimisticLiked : _optimisticPassed; + final opposite = isLikedSegment ? _optimisticPassed : _optimisticLiked; + final serverIds = {}; + final merged = []; + + for (final p in serverItems) { + serverIds.add(p.id); + optimistic.remove(p.id); + opposite.remove(p.id); + merged.add(p); + } + + final pending = optimistic.values.where((p) => !serverIds.contains(p.id)).toList(); + if (pending.isEmpty) return merged; + + DebugLogger.debug( + '๐Ÿ’– Preserving ${pending.length} optimistic ' + '${isLikedSegment ? 'liked' : 'passed'} properties after server merge', + ); + return [...pending, ...merged]; + } + + /// Snapshots the current likes list into the per-segment cache after a fetch. + void syncLikesSegmentCacheFromVisible({required bool hasMore, String? nextCursor}) { + final segment = currentLikesSegment; + final ps = likesState.value; + _likesSegmentCache[segment] = _LikesSegmentCache( + properties: List.of(ps.properties), + lastFetched: ps.lastFetched ?? DateTime.now(), + hasMore: hasMore, + nextCursor: nextCursor, + ); + } + + /// Prepends [property] to the currently visible likes list (no segment check). + void _prependToVisibleLikesList(PropertyModel property) { final state = likesState.value; final exists = state.properties.any((p) => p.id == property.id); - if (!exists) { - final updatedList = [property, ...state.properties]; - updatePageState(PageType.likes, state.copyWith(properties: updatedList)); - } + if (exists) return; + updatePageState(PageType.likes, state.copyWith(properties: [property, ...state.properties])); + } + + void _upsertLikesSegmentCache(String segment, PropertyModel property) { + final existing = _likesSegmentCache[segment]; + final list = List.of(existing?.properties ?? const []); + list.removeWhere((p) => p.id == property.id); + list.insert(0, property); + _likesSegmentCache[segment] = _LikesSegmentCache( + properties: list, + lastFetched: existing?.lastFetched ?? DateTime.now(), + hasMore: existing?.hasMore ?? true, + nextCursor: existing?.nextCursor, + ); + } + + void _removeFromLikesSegmentCache(String segment, int propertyId) { + final existing = _likesSegmentCache[segment]; + if (existing == null) return; + final list = existing.properties.where((p) => p.id != propertyId).toList(); + if (list.length == existing.properties.length) return; + _likesSegmentCache[segment] = _LikesSegmentCache( + properties: list, + lastFetched: existing.lastFetched, + hasMore: existing.hasMore, + nextCursor: existing.nextCursor, + ); } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/lib/core/data/models/popular_city.dart b/lib/core/data/models/popular_city.dart new file mode 100644 index 00000000..40829497 --- /dev/null +++ b/lib/core/data/models/popular_city.dart @@ -0,0 +1,105 @@ +import 'package:ghar360/core/data/models/unified_filter_model.dart'; +import 'package:ghar360/core/services/google_places_service.dart'; + +/// Curated quick-pick cities for location search (NCR focus). +/// +/// Shown at the top of location pickers and merged into autocomplete so +/// common destinations are always one tap away even when Places APIs lag. +class PopularCity { + final String name; + final String region; + final double latitude; + final double longitude; + + /// Alternate spellings / names used for local filtering (e.g. Gurgaon/Gurugram). + final List aliases; + + const PopularCity({ + required this.name, + required this.region, + required this.latitude, + required this.longitude, + this.aliases = const [], + }); + + LocationData toLocationData() => + LocationData(name: name, latitude: latitude, longitude: longitude); + + PlaceSuggestion toPlaceSuggestion() { + final placeId = 'popular:$name|$latitude|$longitude'; + return PlaceSuggestion( + placeId: placeId, + description: '$name, $region', + mainText: name, + secondaryText: region, + latitude: latitude, + longitude: longitude, + ); + } + + bool matchesQuery(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + if (name.toLowerCase().contains(q)) return true; + if (region.toLowerCase().contains(q)) return true; + return aliases.any((a) => a.toLowerCase().contains(q)); + } + + static bool isPopularPlaceId(String placeId) => placeId.startsWith('popular:'); + + /// Default popular cities for the India NCR market. + static const List defaults = [ + PopularCity( + name: 'Gurgaon', + region: 'Haryana, India', + latitude: 28.4595, + longitude: 77.0266, + aliases: ['gurugram', 'gurgaon'], + ), + PopularCity( + name: 'Noida', + region: 'Uttar Pradesh, India', + latitude: 28.5355, + longitude: 77.3910, + aliases: ['noida'], + ), + PopularCity( + name: 'Delhi', + region: 'Delhi, India', + latitude: 28.6139, + longitude: 77.2090, + aliases: ['new delhi', 'delhi ncr', 'ncr'], + ), + ]; + + /// Cities matching [query] (all when empty). + static List matching(String query) { + return defaults.where((c) => c.matchesQuery(query)).toList(growable: false); + } + + /// Place suggestions for UI lists, filtered by [query]. + static List suggestionsForQuery(String query) { + return matching(query).map((c) => c.toPlaceSuggestion()).toList(growable: false); + } + + /// Merges popular matches ahead of remote suggestions, de-duplicating by + /// exact case-insensitive main text only. + /// + /// Do not use substring matching on main text/description โ€” that drops + /// legitimate areas like "Greater Noida" when popular "Noida" is present. + static List mergeWithRemote(String query, List remote) { + final popular = suggestionsForQuery(query); + if (popular.isEmpty) return remote; + + final seen = {for (final p in popular) p.mainText.trim().toLowerCase()}; + + final merged = [...popular]; + for (final r in remote) { + final key = r.mainText.trim().toLowerCase(); + if (key.isEmpty || seen.contains(key)) continue; + seen.add(key); + merged.add(r); + } + return merged; + } +} diff --git a/lib/core/services/google_places_service.dart b/lib/core/services/google_places_service.dart index 94663d36..e12c23f7 100644 --- a/lib/core/services/google_places_service.dart +++ b/lib/core/services/google_places_service.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; import 'package:get/get.dart'; import 'package:ghar360/core/config/app_config.dart'; +import 'package:ghar360/core/data/models/popular_city.dart'; import 'package:ghar360/core/data/models/unified_filter_model.dart'; import 'package:ghar360/core/utils/debug_logger.dart'; import 'package:http/http.dart' as http; @@ -145,15 +146,17 @@ class GooglePlacesService extends GetxService { _clearError(); try { + List? googleResults; if (_shouldTryGoogle) { - final googleResults = await _searchGooglePlaces(query, currentPosition: currentPosition); + googleResults = await _searchGooglePlaces(query, currentPosition: currentPosition); if (!_isLatestSuggestions(requestId)) return []; - if (googleResults != null) { + // Non-empty Google results are preferred; empty/null falls through to OSM. + if (googleResults != null && googleResults.isNotEmpty) { placeSuggestions.value = googleResults; _clearError(); return googleResults; } - // null => Google failed (denied/missing/error) โ€” try fallback + // null => Google failed; [] => zero results โ€” try OSM for broader coverage } if (!_isLatestSuggestions(requestId)) return []; @@ -208,14 +211,20 @@ class GooglePlacesService extends GetxService { 'input': query, 'components': 'country:$countryCode', 'key': apiKey, + // Prefer geographies / cities over establishments for property search. + 'types': '(regions)', }; - if (currentPosition != null) { + // Soft location bias only โ€” never strictbounds. A tight radius + + // strictbounds previously hid distant cities (e.g. Gurgaon when the + // user is elsewhere). Country filter is enough for city/area search. + if (currentPosition != null && !config.placesStrictBounds) { queryParams['location'] = '${currentPosition.latitude},${currentPosition.longitude}'; - queryParams['radius'] = config.placesRadiusMeters; - if (config.placesStrictBounds) { - queryParams['strictbounds'] = 'true'; - } + // Cap bias radius at 200km so nearby areas rank higher without + // excluding other metros in the same country. + final configured = int.tryParse(config.placesRadiusMeters) ?? 25000; + final biasMeters = configured.clamp(25000, 200000); + queryParams['radius'] = '$biasMeters'; } final url = Uri.https( @@ -401,6 +410,22 @@ class GooglePlacesService extends GetxService { } } + // Popular cities encode lat/lng: popular:Gurgaon|28.45|77.02 + if (PopularCity.isPopularPlaceId(placeId)) { + final parsed = _parsePopularPlaceId(placeId); + if (!_isLatestDetails(requestId)) return null; + if (parsed != null) { + _clearError(); + return LocationData( + name: (preferredName != null && preferredName.isNotEmpty) ? preferredName : parsed.$3, + latitude: parsed.$1, + longitude: parsed.$2, + ); + } + _setError('location_details_failed'.tr); + return null; + } + // OSM place ids encode lat/lng: osm:node123|28.6|77.2 if (placeId.startsWith(_osmPlaceIdPrefix)) { final parsed = _parseOsmPlaceId(placeId); @@ -437,6 +462,21 @@ class GooglePlacesService extends GetxService { } } + /// Returns (lat, lng, name) for popular:Name|lat|lng ids. + (double, double, String)? _parsePopularPlaceId(String placeId) { + try { + final body = placeId.substring('popular:'.length); + final parts = body.split('|'); + if (parts.length < 3) return null; + final lat = double.tryParse(parts[1]); + final lng = double.tryParse(parts[2]); + if (lat == null || lng == null) return null; + return (lat, lng, parts[0]); + } catch (_) { + return null; + } + } + Future _getGooglePlaceDetails(String placeId, {String? preferredName}) async { try { final apiKey = AppConfig.instance.googlePlacesApiKey; diff --git a/lib/core/translations/app_translations.dart b/lib/core/translations/app_translations.dart index afffa80e..645bbd18 100644 --- a/lib/core/translations/app_translations.dart +++ b/lib/core/translations/app_translations.dart @@ -331,7 +331,6 @@ class AppTranslations extends Translations { 'no_visits': 'No visits scheduled', // Search - 'search_properties': 'Search Properties', 'search_hint': 'Search by location, property type...', 'recent_searches': 'Recent Searches', @@ -524,7 +523,6 @@ class AppTranslations extends Translations { 'onboarding_chip_support': 'Concierge Support', 'passed': 'Passed', - 'my_location': 'My Location', 'light': 'Light', 'dark': 'Dark', // Common & UI @@ -1517,7 +1515,6 @@ class AppTranslations extends Translations { 'no_visits': 'เค•เฅ‹เคˆ เคฆเฅŒเคฐเคพ เคจเคฟเคฐเฅเคงเคพเคฐเคฟเคค เคจเคนเฅ€เค‚', // Search - 'search_properties': 'เคธเค‚เคชเคคเฅเคคเคฟ เค–เฅ‹เคœเฅ‡เค‚', 'search_hint': 'เคธเฅเคฅเคพเคจ, เคธเค‚เคชเคคเฅเคคเคฟ เคชเฅเคฐเค•เคพเคฐ เค•เฅ‡ เคฆเฅเคตเคพเคฐเคพ เค–เฅ‹เคœเฅ‡เค‚...', 'recent_searches': 'เคนเคพเคฒ เค•เฅ€ เค–เฅ‹เคœเฅ‡เค‚', @@ -1689,7 +1686,6 @@ class AppTranslations extends Translations { 'switch_to_light_mode': 'เคฒเคพเค‡เคŸ เคฎเฅ‹เคก เคชเคฐ เคธเฅเคตเคฟเคš เค•เคฐเฅ‡เค‚', 'passed': 'เคชเคพเคธ', - 'my_location': 'เคฎเฅ‡เคฐเคพ เคธเฅเคฅเคพเคจ', 'light': 'เคนเคฒเฅเค•เคพ', 'dark': 'เค—เคนเคฐเคพ', // Common & UI diff --git a/lib/core/widgets/common/location_selector.dart b/lib/core/widgets/common/location_selector.dart index 8fb2ccd5..245835f9 100644 --- a/lib/core/widgets/common/location_selector.dart +++ b/lib/core/widgets/common/location_selector.dart @@ -7,6 +7,7 @@ import 'package:get/get.dart'; import 'package:ghar360/core/controllers/location_controller.dart'; import 'package:ghar360/core/controllers/page_state_service.dart'; import 'package:ghar360/core/data/models/page_state_model.dart'; +import 'package:ghar360/core/data/models/popular_city.dart'; import 'package:ghar360/core/design/app_design_extensions.dart'; import 'package:ghar360/core/services/google_places_service.dart'; import 'package:ghar360/core/utils/app_toast.dart'; @@ -169,14 +170,19 @@ class _LocationPickerModalState extends State { void _onSearchChanged(String query) { _searchDebounce?.cancel(); - if (query.trim().isEmpty) { + final trimmed = query.trim(); + if (trimmed.isEmpty) { locationController.clearPlaceSuggestions(); + // Rebuild so popular cities reappear when the field is cleared. + if (mounted) setState(() {}); return; } + // Immediate rebuild so popular-city filter updates while typing. + if (mounted) setState(() {}); _searchDebounce = Timer(const Duration(milliseconds: 400), () { // Skip if the field was cleared/changed while the timer was pending. - if (!mounted || _searchController.text.trim() != query.trim()) return; - locationController.getPlaceSuggestions(query.trim()); + if (!mounted || _searchController.text.trim() != trimmed) return; + locationController.getPlaceSuggestions(trimmed); }); } @@ -310,13 +316,18 @@ class _LocationPickerModalState extends State { const SizedBox(height: 8), - // Search results + // Search results + popular cities Expanded( child: Obx(() { - final suggestions = locationController.placeSuggestions; + final remote = locationController.placeSuggestions.toList(growable: false); final placesError = locationController.placesError.value; final isSearching = locationController.isSearchingPlaces.value; - final hasQuery = _searchController.text.trim().isNotEmpty; + final query = _searchController.text.trim(); + final hasQuery = query.isNotEmpty; + final suggestions = PopularCity.mergeWithRemote(query, remote); + final popularOnly = PopularCity.suggestionsForQuery(query); + final showPopularHeader = + popularOnly.isNotEmpty && (remote.isEmpty || !hasQuery); if (isSearching && suggestions.isEmpty) { return const Center( @@ -326,7 +337,10 @@ class _LocationPickerModalState extends State { ); } - if (placesError.isNotEmpty && suggestions.isEmpty && hasQuery) { + if (placesError.isNotEmpty && + suggestions.isEmpty && + hasQuery && + popularOnly.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), @@ -362,13 +376,34 @@ class _LocationPickerModalState extends State { ); } + if (suggestions.isEmpty) { + return const SizedBox.shrink(); + } + return ListView.builder( - itemCount: suggestions.length, + itemCount: suggestions.length + (showPopularHeader ? 1 : 0), itemBuilder: (context, index) { - final suggestion = suggestions[index]; + if (showPopularHeader && index == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + 'popular_cities'.tr, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppDesign.textSecondary, + letterSpacing: 0.2, + ), + ), + ); + } + final suggestionIndex = showPopularHeader ? index - 1 : index; + final suggestion = suggestions[suggestionIndex]; + final isPopular = PopularCity.isPopularPlaceId(suggestion.placeId); return _buildLocationTile( title: suggestion.mainText, subtitle: suggestion.secondaryText, + isPopular: isPopular, onTap: () => _selectPlaceSuggestion(suggestion), ); }, @@ -482,17 +517,19 @@ class _LocationPickerModalState extends State { required String title, required String subtitle, required VoidCallback onTap, + bool isPopular = false, }) { + final accent = isPopular ? AppDesign.primaryYellow : AppDesign.accentBlue; return Material( color: AppDesign.transparent, child: ListTile( leading: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppDesign.accentBlue.withValues(alpha: 0.1), + color: accent.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), - child: const Icon(Icons.location_on, color: AppDesign.accentBlue, size: 20), + child: Icon(isPopular ? Icons.location_city : Icons.location_on, color: accent, size: 20), ), title: Text( title, diff --git a/lib/features/discover/presentation/widgets/property_swipe_card.dart b/lib/features/discover/presentation/widgets/property_swipe_card.dart index f0883d14..d50dc0f9 100644 --- a/lib/features/discover/presentation/widgets/property_swipe_card.dart +++ b/lib/features/discover/presentation/widgets/property_swipe_card.dart @@ -10,14 +10,12 @@ import 'package:ghar360/features/discover/presentation/widgets/swipe_card_hero_s /// and details below. Composes [SwipeCardHeroSection] and /// [SwipeCardDetailsSection] inside the card chrome only. /// -/// Vertical scroll and Pass/Details/Like actions live in [PropertySwipeStack] -/// so the action bar can sit **after** the card in the scroll trail without -/// being painted inside this rounded surface. +/// Vertical scroll is owned by [PropertySwipeStack]. Like / Pass are +/// gesture-only (no action bar) to keep the deck uncluttered. /// /// Gesture map (see also [PropertySwipeStack]): /// - Hero tap / View details โ†’ [onTap] (property details) -/// - Vertical scroll โ†’ owned by the stack (card + trailing actions) -/// - Pass / Details / Like live **outside** this card (scroll trail) +/// - Vertical scroll โ†’ owned by the stack /// - Embedded interactive children (e.g. 360 tour) signal via /// [onInteractionStart]/[onInteractionEnd] so the stack can block deck swipes class PropertySwipeCard extends StatelessWidget { diff --git a/lib/features/discover/presentation/widgets/property_swipe_stack.dart b/lib/features/discover/presentation/widgets/property_swipe_stack.dart index 02fd7580..2261dab7 100644 --- a/lib/features/discover/presentation/widgets/property_swipe_stack.dart +++ b/lib/features/discover/presentation/widgets/property_swipe_stack.dart @@ -12,7 +12,6 @@ import 'package:ghar360/core/utils/app_spacing.dart'; import 'package:ghar360/core/widgets/common/error_states.dart'; import 'package:ghar360/core/widgets/common/robust_network_image.dart'; import 'package:ghar360/features/discover/presentation/widgets/property_swipe_card.dart'; -import 'package:ghar360/features/discover/presentation/widgets/swipe_card_action_buttons.dart'; /// Immutable drag state for the swipe gesture, driven by a [ValueNotifier] /// so only the transform wrapper rebuilds during drag โ€” not the card content. @@ -39,10 +38,12 @@ class _SwipeDragState { /// Gesture map: /// - Horizontal drag (stack) โ†’ like / pass /// - Hero tap / View details โ†’ [onSwipeUp] (property details) -/// - Vertical scroll โ†’ full card details (actions stay pinned on first viewport) -/// - Pass/Details/Like โ†’ floating bar at bottom of the deck viewport +/// - Vertical scroll โ†’ full card details /// - Gallery chevrons (hero) โ†’ change photo only /// - 360 interaction โ†’ block stack gestures via [onInteractionStart] +/// +/// Like / Pass / Info action buttons were removed โ€” swipe gestures cover +/// those actions without cluttering the deck. class PropertySwipeStack extends StatefulWidget { final List properties; final Function(PropertyModel) onSwipeLeft; @@ -92,7 +93,7 @@ class _PropertySwipeStackState extends State with TickerProv bool _blockGestures = false; bool _isExiting = false; - /// True while the stack must ignore pan / button swipes (exit, drag block, anim). + /// True while the stack must ignore pan gestures (exit, drag block, anim). bool get _gesturesLocked => _blockGestures || _isExiting || _swipeAnimationController.isAnimating; @override @@ -269,31 +270,6 @@ class _PropertySwipeStackState extends State with TickerProv controller.forward(); } - /// Programmatic like/pass from action buttons โ€” same exit path as drag. - void _animateSwipeOff({required bool isRight, required double cardWidth}) { - if (_properties.isEmpty || _gesturesLocked || _dragNotifier.value.isDragging) { - return; - } - - final dx = isRight ? cardWidth * 0.4 : -cardWidth * 0.4; - _dragNotifier.value = _SwipeDragState( - position: Offset(dx, 0), - rotation: isRight ? 0.22 : -0.22, - isDragging: false, - ); - - if (isRight) { - _isSwipingRight = true; - _showSparkles = true; - _sparklesAnimationController.forward(); - widget.onSwipeRight(_properties[0]); - } else { - widget.onSwipeLeft(_properties[0]); - } - setState(() => _isExiting = true); - _swipeAnimationController.forward(); - } - void _openDetails() { if (_properties.isEmpty || _gesturesLocked) { return; @@ -455,21 +431,6 @@ class _PropertySwipeStackState extends State with TickerProv }, ), ), - - // Pin Like/Pass/Details on the first viewport so users never have - // to scroll past the full card to find primary actions. - if (!_isExiting) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: SwipeCardActionButtons( - onPass: () => _animateSwipeOff(isRight: false, cardWidth: cardWidth), - onDetails: _openDetails, - onLike: () => _animateSwipeOff(isRight: true, cardWidth: cardWidth), - enabled: !_gesturesLocked, - ), - ), ], ), ); @@ -477,12 +438,10 @@ class _PropertySwipeStackState extends State with TickerProv ); } - /// Scrollable card chrome. Bottom inset keeps details clear of the floating - /// action bar. Opaque fill prevents stacked cards showing through gaps. + /// Scrollable card chrome. Opaque fill prevents stacked cards showing + /// through gaps. Card fills the deck viewport for a cleaner swipe UI. Widget _buildScrollableDeck({required double cardWidth, required double deckHeight}) { final trayColor = AppDesign.scaffoldBackground; - // Room for the floating action bar (~56px buttons + vertical padding). - const floatingActionsInset = 96.0; return SingleChildScrollView( // Keyed so scroll position resets when the top property changes. @@ -490,32 +449,24 @@ class _PropertySwipeStackState extends State with TickerProv physics: _blockGestures ? const NeverScrollableScrollPhysics() : const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - minHeight: (deckHeight - floatingActionsInset).clamp(1.0, double.infinity), - ), - child: ColoredBox( - color: trayColor, - child: Align( - alignment: Alignment.topCenter, - child: PropertySwipeCard( - property: _properties[0], - onTap: _openDetails, - onInteractionStart: () { - setState(() => _blockGestures = true); - }, - onInteractionEnd: () { - setState(() => _blockGestures = false); - }, - ), - ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: deckHeight), + child: ColoredBox( + color: trayColor, + child: Align( + alignment: Alignment.topCenter, + child: PropertySwipeCard( + property: _properties[0], + onTap: _openDetails, + onInteractionStart: () { + setState(() => _blockGestures = true); + }, + onInteractionEnd: () { + setState(() => _blockGestures = false); + }, ), ), - const SizedBox(height: floatingActionsInset), - ], + ), ), ); } diff --git a/lib/features/discover/presentation/widgets/swipe_card_action_buttons.dart b/lib/features/discover/presentation/widgets/swipe_card_action_buttons.dart deleted file mode 100644 index c10e917d..00000000 --- a/lib/features/discover/presentation/widgets/swipe_card_action_buttons.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:get/get.dart'; - -import 'package:ghar360/core/design/app_design_extensions.dart'; -import 'package:ghar360/core/utils/app_spacing.dart'; - -/// Pass / Details / Like controls for the discover deck. -/// -/// Pinned to the bottom of the swipe viewport so primary actions stay -/// visible on the first screen without scrolling past the card. -class SwipeCardActionButtons extends StatelessWidget { - final VoidCallback? onPass; - final VoidCallback? onDetails; - final VoidCallback? onLike; - final bool enabled; - - const SwipeCardActionButtons({ - super.key, - this.onPass, - this.onDetails, - this.onLike, - this.enabled = true, - }); - - @override - Widget build(BuildContext context) { - return IgnorePointer( - ignoring: !enabled, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.lg, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _ActionCircleButton( - key: const ValueKey('qa.discover.action.pass'), - icon: Icons.close_rounded, - color: AppDesign.errorRed, - semanticLabel: 'passed'.tr, - size: 56, - onPressed: onPass, - ), - const SizedBox(width: 20), - _ActionCircleButton( - key: const ValueKey('qa.discover.action.details'), - icon: Icons.info_outline_rounded, - color: AppDesign.primaryYellow, - semanticLabel: 'view_details'.tr, - size: 48, - onPressed: onDetails, - ), - const SizedBox(width: 20), - _ActionCircleButton( - key: const ValueKey('qa.discover.action.like'), - icon: Icons.favorite_rounded, - color: AppDesign.successGreen, - semanticLabel: 'liked'.tr, - size: 56, - onPressed: onLike, - ), - ], - ), - ), - ); - } -} - -class _ActionCircleButton extends StatelessWidget { - final IconData icon; - final Color color; - final String semanticLabel; - final double size; - final VoidCallback? onPressed; - - const _ActionCircleButton({ - super.key, - required this.icon, - required this.color, - required this.semanticLabel, - required this.size, - required this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - label: semanticLabel, - child: Material( - color: AppDesign.darkTextPrimary.withValues(alpha: 0.88), - shape: const CircleBorder(), - elevation: 4, - shadowColor: AppDesign.shadowColor, - child: InkWell( - customBorder: const CircleBorder(), - onTap: onPressed, - child: SizedBox( - width: size, - height: size, - child: Icon(icon, color: color, size: size * 0.42), - ), - ), - ), - ); - } -} diff --git a/lib/features/likes/presentation/controllers/likes_controller.dart b/lib/features/likes/presentation/controllers/likes_controller.dart index 0a80c2b4..a39e2938 100644 --- a/lib/features/likes/presentation/controllers/likes_controller.dart +++ b/lib/features/likes/presentation/controllers/likes_controller.dart @@ -100,6 +100,9 @@ class LikesController extends GetxController { return; } + // Prefer optimistic + cached data. Only network-refresh when empty or + // stale โ€” a refresh-on-every-visit races the swipe POST and used to + // flash-remove just-liked properties before the history API had them. if (ps.properties.isEmpty) { DebugLogger.debug('๐Ÿ’– [LIKES_CONTROLLER] No properties, loading data'); _pageStateService.loadPageData(PageType.likes, forceRefresh: true); @@ -107,7 +110,9 @@ class LikesController extends GetxController { DebugLogger.debug('๐Ÿ’– [LIKES_CONTROLLER] Data is stale, refreshing in background'); _pageStateService.loadPageData(PageType.likes, backgroundRefresh: true); } else { - DebugLogger.debug('๐Ÿ’– [LIKES_CONTROLLER] Data is current, no action needed'); + DebugLogger.debug( + '๐Ÿ’– [LIKES_CONTROLLER] Data is current (${ps.properties.length} items), no refresh', + ); } } catch (e, stackTrace) { DebugLogger.error('โŒ [LIKES_CONTROLLER] Error in activatePage: $e'); diff --git a/lib/features/location_search/presentation/views/location_search_view.dart b/lib/features/location_search/presentation/views/location_search_view.dart index 4879572c..3705b795 100644 --- a/lib/features/location_search/presentation/views/location_search_view.dart +++ b/lib/features/location_search/presentation/views/location_search_view.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ghar360/core/controllers/location_controller.dart'; +import 'package:ghar360/core/data/models/popular_city.dart'; import 'package:ghar360/core/design/app_design_extensions.dart'; +import 'package:ghar360/core/services/google_places_service.dart'; import 'package:ghar360/features/location_search/presentation/controllers/location_search_controller.dart'; class LocationSearchView extends GetView { @@ -102,40 +104,75 @@ class LocationSearchView extends GetView { return Obx(() { if (controller.isLoading.value || locationController.isSearchingPlaces.value) { - return const Center(child: CircularProgressIndicator()); + final query = controller.searchQuery.value.trim(); + final remote = locationController.placeSuggestions.toList(growable: false); + final suggestions = PopularCity.mergeWithRemote(query, remote); + // Keep showing known results (incl. popular cities) while a network + // search is in flight instead of blanking the list. + if (suggestions.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } } if (controller.searchError.value.isNotEmpty) { - return _buildErrorState(context); + final query = controller.searchQuery.value.trim(); + final popular = PopularCity.suggestionsForQuery(query); + if (popular.isEmpty) { + return _buildErrorState(context); + } } - final suggestions = locationController.placeSuggestions; + final query = controller.searchQuery.value.trim(); + final remote = locationController.placeSuggestions.toList(growable: false); + final suggestions = PopularCity.mergeWithRemote(query, remote); + final popularOnly = PopularCity.suggestionsForQuery(query); + final showPopularHeader = popularOnly.isNotEmpty && (remote.isEmpty || query.isEmpty); - if (suggestions.isEmpty && controller.searchQuery.value.isNotEmpty) { + if (suggestions.isEmpty && query.isNotEmpty) { return _buildEmptyState(context); } - // If there are no suggestions yet and no query, show a gentle prompt if (suggestions.isEmpty) { return _buildSearchPrompt(context); } return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: suggestions.length, + itemCount: suggestions.length + (showPopularHeader ? 1 : 0), itemBuilder: (context, index) { - final suggestion = suggestions[index]; - return ListTile( - leading: const Icon(Icons.location_on_outlined), - title: Text(suggestion.mainText), - subtitle: suggestion.secondaryText.isNotEmpty ? Text(suggestion.secondaryText) : null, - onTap: () => controller.selectPlace(suggestion), - ); + if (showPopularHeader && index == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + 'popular_cities'.tr, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w600, + ), + ), + ); + } + final suggestionIndex = showPopularHeader ? index - 1 : index; + final suggestion = suggestions[suggestionIndex]; + return _buildSuggestionTile(context, suggestion); }, ); }); } + Widget _buildSuggestionTile(BuildContext context, PlaceSuggestion suggestion) { + final isPopular = PopularCity.isPopularPlaceId(suggestion.placeId); + return ListTile( + leading: Icon( + isPopular ? Icons.location_city : Icons.location_on_outlined, + color: isPopular ? AppDesign.primaryYellow : null, + ), + title: Text(suggestion.mainText), + subtitle: suggestion.secondaryText.isNotEmpty ? Text(suggestion.secondaryText) : null, + onTap: () => controller.selectPlace(suggestion), + ); + } + Widget _buildEmptyState(BuildContext context) { return Center( child: Column( diff --git a/test/core/controllers/page_data_loader_test.dart b/test/core/controllers/page_data_loader_test.dart index 2483dddd..688bf53b 100644 --- a/test/core/controllers/page_data_loader_test.dart +++ b/test/core/controllers/page_data_loader_test.dart @@ -59,6 +59,18 @@ void main() { ).thenAnswer((inv) => PageStateModel.initial(inv.positionalArguments[0] as PageType)); when(() => pageState.updatePageState(any(), any())).thenReturn(null); when(() => pageState.notifyPageRefreshing(any(), any())).thenReturn(null); + when( + () => pageState.filterOutSessionSwiped(any()), + ).thenAnswer((inv) => List.from(inv.positionalArguments[0] as List)); + when( + () => pageState.mergeLikesServerResults(any(), isLikedSegment: any(named: 'isLikedSegment')), + ).thenAnswer((inv) => List.from(inv.positionalArguments[0] as List)); + when( + () => pageState.syncLikesSegmentCacheFromVisible( + hasMore: any(named: 'hasMore'), + nextCursor: any(named: 'nextCursor'), + ), + ).thenReturn(null); // LocationController stubs. when( diff --git a/test/core/controllers/page_filter_manager_test.dart b/test/core/controllers/page_filter_manager_test.dart index dc80676b..a26d29f5 100644 --- a/test/core/controllers/page_filter_manager_test.dart +++ b/test/core/controllers/page_filter_manager_test.dart @@ -177,7 +177,7 @@ void main() { }); test('ignores seed text on subsequent calls', () { - final c1 = manager.getOrCreateSearchController(PageType.explore, seedText: 'hello'); + manager.getOrCreateSearchController(PageType.explore, seedText: 'hello'); final c2 = manager.getOrCreateSearchController(PageType.explore, seedText: 'world'); expect(c2.text, 'hello'); }); diff --git a/test/core/controllers/page_state_service_test.dart b/test/core/controllers/page_state_service_test.dart index 8905f3b3..072da42f 100644 --- a/test/core/controllers/page_state_service_test.dart +++ b/test/core/controllers/page_state_service_test.dart @@ -322,11 +322,102 @@ void main() { // Removed from discover expect(service.discoverState.value.properties.any((p) => p.id == 99), isFalse); - // NOT added to likes + // NOT added to visible liked list (default segment is liked) expect(service.likesState.value.properties.any((p) => p.id == 99), isFalse); verify(() => swipesRepo.recordSwipe(propertyId: 99, isLiked: false)).called(1); }); + test('liked swipe updates likes even when current segment is passed', () async { + final service = await createService(); + final prop = testPropertyModel(id: 88); + + service.updatePageState( + PageType.discover, + service.discoverState.value.copyWith(properties: [prop]), + ); + // Seed liked segment so switching away caches it, then open passed. + service.updatePageState( + PageType.likes, + service.likesState.value.copyWith(properties: [testPropertyModel(id: 1)]), + ); + service.updateLikesSegment('passed'); + expect(service.currentLikesSegment, 'passed'); + + await service.recordSwipe(propertyId: 88, isLiked: true); + + // Visible list is still the passed segment (empty after switch without cache). + expect(service.likesState.value.properties.any((p) => p.id == 88), isFalse); + + // Switching back to liked should surface the optimistically cached like. + service.updateLikesSegment('liked'); + expect(service.likesState.value.properties.any((p) => p.id == 88), isTrue); + }); + + test('passed swipe appears when switching to passed segment', () async { + final service = await createService(); + final prop = testPropertyModel(id: 77); + + service.updatePageState( + PageType.discover, + service.discoverState.value.copyWith(properties: [prop]), + ); + expect(service.currentLikesSegment, 'liked'); + + await service.recordSwipe(propertyId: 77, isLiked: false); + + expect(service.likesState.value.properties.any((p) => p.id == 77), isFalse); + + service.updateLikesSegment('passed'); + expect(service.likesState.value.properties.any((p) => p.id == 77), isTrue); + }); + + test('session-swiped properties stay out of discover after filter', () async { + final service = await createService(); + final swiped = testPropertyModel(id: 901); + final other = testPropertyModel(id: 902); + + service.updatePageState( + PageType.discover, + service.discoverState.value.copyWith(properties: [swiped, other]), + ); + + await service.recordSwipe(propertyId: 901, isLiked: true); + + expect(service.discoverState.value.properties.any((p) => p.id == 901), isFalse); + expect(service.isSessionSwiped(901), isTrue); + + // Simulate API refresh still returning the swiped property. + final filtered = service.filterOutSessionSwiped([swiped, other]); + expect(filtered.map((p) => p.id), [902]); + + // Undo restores eligibility for the deck. + service.reinsertPropertyToDiscover(swiped); + expect(service.isSessionSwiped(901), isFalse); + expect(service.filterOutSessionSwiped([swiped, other]).map((p) => p.id), [901, 902]); + }); + + test('mergeLikesServerResults keeps optimistic like until server returns it', () async { + final service = await createService(); + final prop = testPropertyModel(id: 501); + final older = testPropertyModel(id: 100); + + service.updatePageState( + PageType.discover, + service.discoverState.value.copyWith(properties: [prop]), + ); + await service.recordSwipe(propertyId: 501, isLiked: true); + + // Server history is still missing the just-liked property (race). + final merged = service.mergeLikesServerResults([older], isLikedSegment: true); + expect(merged.map((p) => p.id), [501, 100]); + + // Once server includes it, optimistic is cleared and not duplicated. + final merged2 = service.mergeLikesServerResults([prop, older], isLikedSegment: true); + expect(merged2.map((p) => p.id), [501, 100]); + final merged3 = service.mergeLikesServerResults([prop, older], isLikedSegment: true); + expect(merged3.map((p) => p.id), [501, 100]); + }); + test('liked swipe finds property in explore list too', () async { final service = await createService(); final prop = testPropertyModel(id: 55); diff --git a/test/core/data/models/popular_city_test.dart b/test/core/data/models/popular_city_test.dart new file mode 100644 index 00000000..24f3dd36 --- /dev/null +++ b/test/core/data/models/popular_city_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ghar360/core/data/models/popular_city.dart'; +import 'package:ghar360/core/services/google_places_service.dart'; + +void main() { + group('PopularCity', () { + test('defaults include Gurgaon, Noida, and Delhi', () { + final names = PopularCity.defaults.map((c) => c.name).toSet(); + expect(names, containsAll(['Gurgaon', 'Noida', 'Delhi'])); + }); + + test('matchesQuery finds Gurgaon via Gurugram alias', () { + final city = PopularCity.defaults.firstWhere((c) => c.name == 'Gurgaon'); + expect(city.matchesQuery('gurgaon'), isTrue); + expect(city.matchesQuery('Gurugram'), isTrue); + expect(city.matchesQuery('xyz'), isFalse); + }); + + test('suggestionsForQuery returns all when query empty', () { + expect(PopularCity.suggestionsForQuery(''), hasLength(PopularCity.defaults.length)); + }); + + test('suggestionsForQuery filters by name', () { + final results = PopularCity.suggestionsForQuery('noi'); + expect(results, hasLength(1)); + expect(results.first.mainText, 'Noida'); + expect(PopularCity.isPopularPlaceId(results.first.placeId), isTrue); + }); + + test('mergeWithRemote puts popular cities first and de-dupes', () { + final remote = [ + PlaceSuggestion( + placeId: 'google-1', + description: 'Noida, Uttar Pradesh, India', + mainText: 'Noida', + secondaryText: 'Uttar Pradesh, India', + ), + PlaceSuggestion( + placeId: 'google-2', + description: 'Sector 18, Noida', + mainText: 'Sector 18', + secondaryText: 'Noida, Uttar Pradesh', + ), + PlaceSuggestion( + placeId: 'google-3', + description: 'Greater Noida, Uttar Pradesh, India', + mainText: 'Greater Noida', + secondaryText: 'Uttar Pradesh, India', + ), + ]; + + final merged = PopularCity.mergeWithRemote('noi', remote); + expect(merged.first.mainText, 'Noida'); + expect(PopularCity.isPopularPlaceId(merged.first.placeId), isTrue); + // Exact mainText de-dupe only โ€” not substring matches. + expect(merged.where((s) => s.mainText == 'Noida'), hasLength(1)); + expect(merged.any((s) => s.mainText == 'Sector 18'), isTrue); + expect(merged.any((s) => s.mainText == 'Greater Noida'), isTrue); + }); + + test('toLocationData exposes coordinates', () { + final city = PopularCity.defaults.first; + final loc = city.toLocationData(); + expect(loc.name, city.name); + expect(loc.latitude, city.latitude); + expect(loc.longitude, city.longitude); + }); + }); +} diff --git a/test/core/firebase/firebase_initializer_enabled_test.dart b/test/core/firebase/firebase_initializer_enabled_test.dart index ad727430..ad683158 100644 --- a/test/core/firebase/firebase_initializer_enabled_test.dart +++ b/test/core/firebase/firebase_initializer_enabled_test.dart @@ -26,39 +26,6 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; // Firebase core mock with Crashlytics plugin constants // --------------------------------------------------------------------------- -class _MockFirebaseAppWithConstants implements TestFirebaseCoreHostApi { - Map get _pluginConstants => { - 'plugins.flutter.io/firebase_crashlytics': { - 'isCrashlyticsCollectionEnabled': true, - }, - }; - - CoreFirebaseOptions get _options => - CoreFirebaseOptions(apiKey: '123', projectId: '123', appId: '123', messagingSenderId: '123'); - - @override - Future initializeApp( - String appName, - CoreFirebaseOptions initializeAppRequest, - ) async { - return CoreInitializeResponse( - name: appName, - options: _options, - pluginConstants: _pluginConstants, - ); - } - - @override - Future> initializeCore() async { - // Return empty so Firebase.initializeApp() creates [DEFAULT] once via - // initializeApp() โ€” pre-seeding DEFAULT here causes duplicate-app errors. - return []; - } - - @override - Future optionsFromResource() async => _options; -} - // --------------------------------------------------------------------------- // Path provider for GetStorage // --------------------------------------------------------------------------- @@ -107,7 +74,6 @@ class _FakeAnalytics extends Fake Map? webOptions, }) => this; - @override FirebaseAnalyticsPlatform setInitialValues({required Map pluginConstants}) => this; diff --git a/test/core/utils/error_handler_test.dart b/test/core/utils/error_handler_test.dart index 7c6275f9..a91edc8b 100644 --- a/test/core/utils/error_handler_test.dart +++ b/test/core/utils/error_handler_test.dart @@ -99,12 +99,8 @@ void main() { test('does not throw with a retry callback', () { final error = const AuthException('Invalid login credentials'); - var retryCalled = false; - expect( - () => ErrorHandler.handleAuthError(error, onRetry: () => retryCalled = true), - returnsNormally, - ); + expect(() => ErrorHandler.handleAuthError(error, onRetry: () {}), returnsNormally); }); test('does not throw with a stackTrace', () { @@ -154,12 +150,8 @@ void main() { test('does not throw with a retry callback', () { final error = NetworkException('Connection failed'); - var retryCalled = false; - expect( - () => ErrorHandler.handleNetworkError(error, onRetry: () => retryCalled = true), - returnsNormally, - ); + expect(() => ErrorHandler.handleNetworkError(error, onRetry: () {}), returnsNormally); }); test('does not throw with a stackTrace', () { diff --git a/test/features/assistant/presentation/widgets/chat_message_bubble_test.dart b/test/features/assistant/presentation/widgets/chat_message_bubble_test.dart index 249e2a81..f8e979ed 100644 --- a/test/features/assistant/presentation/widgets/chat_message_bubble_test.dart +++ b/test/features/assistant/presentation/widgets/chat_message_bubble_test.dart @@ -85,16 +85,7 @@ void main() { // infinite animation. await tester.pump(); - // The typing indicator renders 3 small dot containers. - final dots = tester.widgetList( - find.ancestor( - of: find.byWidgetPredicate( - (w) => w is Container && (w.constraints?.maxWidth == 7 || false), - ), - matching: find.byType(Container), - ), - ); - // Verify the typing indicator is shown (3 dots in a Row). + // Verify the typing indicator is shown (animated dots). expect(find.byType(AnimatedBuilder), findsWidgets); }); diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index 2feabdc5..42d83110 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -54,7 +54,7 @@ void main() { return null; }, ); - await Supabase.initialize(url: 'https://example.supabase.co', anonKey: 'anon-key'); + await Supabase.initialize(url: 'https://example.supabase.co', publishableKey: 'anon-key'); await GetStorage.init(); }); diff --git a/test/features/discover/presentation/views/discover_view_test.dart b/test/features/discover/presentation/views/discover_view_test.dart index 772a3fd5..aff3b0d6 100644 --- a/test/features/discover/presentation/views/discover_view_test.dart +++ b/test/features/discover/presentation/views/discover_view_test.dart @@ -69,9 +69,8 @@ class _MockPageStateService extends GetxServiceMock implements PageStateService class _TestDiscoverController extends DiscoverController { @override - void onInit() { - // No state-sync worker; tests set [state] explicitly. - } + // ignore: must_call_super โ€” skip workers; tests set [state] explicitly. + void onInit() {} @override void onReady() { @@ -199,7 +198,7 @@ void main() { expect(find.byKey(const ValueKey('qa.discover.swipe_stack')), findsOneWidget); }); - testWidgets('loaded state shows action buttons outside the swipe stack card', (tester) async { + testWidgets('loaded state does not show like/pass/info action buttons', (tester) async { pageStateService.discoverState.value = PageStateModel( pageType: PageType.discover, filters: const UnifiedFilterModel(), @@ -211,7 +210,9 @@ void main() { await tester.pump(); expect(find.byType(PropertySwipeStack), findsOneWidget); - expect(find.byKey(const ValueKey('qa.discover.action.like')), findsOneWidget); + expect(find.byKey(const ValueKey('qa.discover.action.like')), findsNothing); + expect(find.byKey(const ValueKey('qa.discover.action.pass')), findsNothing); + expect(find.byKey(const ValueKey('qa.discover.action.details')), findsNothing); expect(find.text('Swipe right to like | Swipe left to pass'), findsNothing); }); diff --git a/test/features/discover/presentation/widgets/property_swipe_stack_test.dart b/test/features/discover/presentation/widgets/property_swipe_stack_test.dart index bc579aab..fd34b718 100644 --- a/test/features/discover/presentation/widgets/property_swipe_stack_test.dart +++ b/test/features/discover/presentation/widgets/property_swipe_stack_test.dart @@ -199,7 +199,7 @@ void main() { expect(find.text('Gamma Villa'), findsNothing); }); - testWidgets('action buttons are below the fold until card is scrolled', (tester) async { + testWidgets('does not show like/pass/info action buttons', (tester) async { final properties = [_property(id: 1, title: 'Alpha Home')]; await pumpStack( tester, @@ -212,21 +212,10 @@ void main() { ); await tester.pump(); - final likeKey = find.byKey(const ValueKey('qa.discover.action.like')); - final passKey = find.byKey(const ValueKey('qa.discover.action.pass')); - final detailsKey = find.byKey(const ValueKey('qa.discover.action.details')); - - // Present in the tree (end of scroll) but not in the first viewport. - expect(likeKey, findsOneWidget); - expect(passKey, findsOneWidget); - expect(detailsKey, findsOneWidget); - expect(tester.getRect(likeKey).top, greaterThanOrEqualTo(700)); + expect(find.byKey(const ValueKey('qa.discover.action.like')), findsNothing); + expect(find.byKey(const ValueKey('qa.discover.action.pass')), findsNothing); + expect(find.byKey(const ValueKey('qa.discover.action.details')), findsNothing); expect(find.text('Swipe right to like | Swipe left to pass'), findsNothing); - - // After scrolling the deck, the action bar is reachable. - await tester.scrollUntilVisible(likeKey, 200, scrollable: find.byType(Scrollable).first); - await tester.pump(); - expect(tester.getRect(likeKey).top, lessThan(700)); }); }); @@ -439,79 +428,6 @@ void main() { }); }); - group('PropertySwipeStack โ€” action buttons', () { - Future scrollToActions(WidgetTester tester, Finder actionFinder) async { - await tester.scrollUntilVisible(actionFinder, 300, scrollable: find.byType(Scrollable).first); - await tester.pump(); - } - - testWidgets('like action button calls onSwipeRight', (tester) async { - PropertyModel? liked; - await pumpStack( - tester, - PropertySwipeStack( - properties: [_property(id: 7, title: 'Like Me')], - onSwipeLeft: (_) {}, - onSwipeRight: (p) => liked = p, - onSwipeUp: (_) {}, - ), - ); - await tester.pump(); - - final likeKey = find.byKey(const ValueKey('qa.discover.action.like')); - await scrollToActions(tester, likeKey); - await tester.tap(likeKey); - await tester.pump(); - - expect(liked, isNotNull); - expect(liked!.id, 7); - }); - - testWidgets('pass action button calls onSwipeLeft', (tester) async { - PropertyModel? passed; - await pumpStack( - tester, - PropertySwipeStack( - properties: [_property(id: 8, title: 'Pass Me')], - onSwipeLeft: (p) => passed = p, - onSwipeRight: (_) {}, - onSwipeUp: (_) {}, - ), - ); - await tester.pump(); - - final passKey = find.byKey(const ValueKey('qa.discover.action.pass')); - await scrollToActions(tester, passKey); - await tester.tap(passKey); - await tester.pump(); - - expect(passed, isNotNull); - expect(passed!.id, 8); - }); - - testWidgets('details action button calls onSwipeUp', (tester) async { - PropertyModel? opened; - await pumpStack( - tester, - PropertySwipeStack( - properties: [_property(id: 9, title: 'Details Me')], - onSwipeLeft: (_) {}, - onSwipeRight: (_) {}, - onSwipeUp: (p) => opened = p, - ), - ); - await tester.pump(); - - final detailsKey = find.byKey(const ValueKey('qa.discover.action.details')); - await scrollToActions(tester, detailsKey); - await tester.tap(detailsKey); - await tester.pump(); - - expect(opened, isNotNull); - expect(opened!.id, 9); - }); - }); - group('PropertySwipeStack โ€” swipe feedback overlay', () { testWidgets('shows like badge text while dragging right', (tester) async { final properties = [_property(id: 1, title: 'Alpha Home')]; diff --git a/test/features/explore/presentation/views/explore_view_test.dart b/test/features/explore/presentation/views/explore_view_test.dart index 7ceb324a..50912c54 100644 --- a/test/features/explore/presentation/views/explore_view_test.dart +++ b/test/features/explore/presentation/views/explore_view_test.dart @@ -150,11 +150,6 @@ PropertyModel _property({int id = 100}) { ); } -/// Finder for a [Semantics] widget whose [label] matches [label]. -Finder _findBySemanticsLabel(String label) { - return find.byWidgetPredicate((w) => w is Semantics && w.properties.label == label); -} - /// Finder for a [Semantics] widget whose [identifier] matches [id]. Finder _findBySemanticsIdentifier(String id) { return find.byWidgetPredicate((w) => w is Semantics && w.properties.identifier == id); diff --git a/test/features/location_search/presentation/views/location_search_view_test.dart b/test/features/location_search/presentation/views/location_search_view_test.dart index 4d9b5ff4..05729889 100644 --- a/test/features/location_search/presentation/views/location_search_view_test.dart +++ b/test/features/location_search/presentation/views/location_search_view_test.dart @@ -114,12 +114,14 @@ void main() { expect(find.byKey(const ValueKey('qa.location_search.search_input')), findsOneWidget); }); - testWidgets('shows search prompt when no query and no suggestions', (tester) async { + testWidgets('shows popular cities when no query and no remote suggestions', (tester) async { await tester.pumpApp(const LocationSearchView()); await tester.pump(); - expect(find.byIcon(Icons.search), findsWidgets); - expect(find.text('search_city_or_area_hint'.tr), findsWidgets); + expect(find.text('popular_cities'.tr), findsOneWidget); + expect(find.text('Gurgaon'), findsOneWidget); + expect(find.text('Noida'), findsOneWidget); + expect(find.text('Delhi'), findsOneWidget); }); testWidgets('shows clear button when query is non-empty and clears on tap', (tester) async { @@ -180,22 +182,29 @@ void main() { expect(find.text('location_found'.tr), findsOneWidget); }); - testWidgets('shows loading indicator when searching places', (tester) async { + testWidgets('shows loading indicator when searching with no local matches', (tester) async { + // Query that matches no popular city and no remote results. + searchController.searchQuery.value = 'Zzqx'; + locationController.suggestions.clear(); locationController.searching.value = true; await tester.pumpApp(const LocationSearchView()); await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 600)); }); - testWidgets('shows loading indicator when controller isLoading', (tester) async { - searchController.isLoading.value = true; + testWidgets('keeps popular cities visible while remote search is in flight', (tester) async { + locationController.searching.value = true; await tester.pumpApp(const LocationSearchView()); await tester.pump(); - expect(find.byType(CircularProgressIndicator), findsOneWidget); + // Empty query still has popular cities โ€” no full-screen spinner. + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Gurgaon'), findsOneWidget); }); testWidgets('shows empty state when query non-empty and no suggestions', (tester) async { @@ -212,14 +221,18 @@ void main() { await tester.pump(const Duration(milliseconds: 600)); }); - testWidgets('shows error state when searchError is set', (tester) async { + testWidgets('shows error state when searchError is set and no local matches', (tester) async { + searchController.searchQuery.value = 'Zzqx'; searchController.searchError.value = 'Something went wrong'; + locationController.suggestions.clear(); await tester.pumpApp(const LocationSearchView()); await tester.pump(); expect(find.byIcon(Icons.error_outline), findsOneWidget); expect(find.text('Something went wrong'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 600)); }); testWidgets('renders suggestion list and tapping a suggestion calls selectPlace', ( diff --git a/test/features/property_details/presentation/views/property_details_view_test.dart b/test/features/property_details/presentation/views/property_details_view_test.dart index 0dcb9068..3993cd9e 100644 --- a/test/features/property_details/presentation/views/property_details_view_test.dart +++ b/test/features/property_details/presentation/views/property_details_view_test.dart @@ -39,11 +39,8 @@ import '../../../../helpers/mocks.dart'; /// `isLoading`, and `errorKey` directly. class FakePropertyDetailsController extends PropertyDetailsController { @override - void onInit() { - // Intentionally do NOT call super.onInit() โ€” avoid Get.arguments / - // repository resolution. Reactive fields are set by the test before - // pumping the widget. - } + // ignore: must_call_super โ€” avoid Get.arguments / repository resolution. + void onInit() {} } /// Fake [LikesController] that skips the real `onInit` (which requires @@ -56,9 +53,8 @@ class FakeLikesController extends LikesController { final RxInt _favouriteVersion = 0.obs; @override - void onInit() { - // Do NOT call super.onInit() โ€” avoid PageStateService worker setup. - } + // ignore: must_call_super โ€” avoid PageStateService worker setup. + void onInit() {} @override bool isFavourite(dynamic propertyId) { @@ -86,9 +82,8 @@ class FakeLikesController extends LikesController { /// directly by tests. class FakeVisitsController extends VisitsController { @override - void onInit() { - // Do NOT call super.onInit() โ€” avoid AuthController / repository calls. - } + // ignore: must_call_super โ€” avoid AuthController / repository calls. + void onInit() {} } // --------------------------------------------------------------------------- diff --git a/test/helpers/fake_webview_platform.dart b/test/helpers/fake_webview_platform.dart index 6848ae22..aba5d8ac 100644 --- a/test/helpers/fake_webview_platform.dart +++ b/test/helpers/fake_webview_platform.dart @@ -198,7 +198,6 @@ class FakeWebResourceError implements WebResourceError { @override final int errorCode; - @override String? get domain => 'fake'; @override From f4eeab88fd3bb4e4f791eb0f70a75e63cc7d4f72 Mon Sep 17 00:00:00 2001 From: Ravi Sahu Date: Thu, 16 Jul 2026 19:47:12 +0530 Subject: [PATCH 2/6] fix: address PR review comments for discover/likes races Resolve CodeRabbit and Cubic findings: pass PropertyModel into recordSwipe, preserve undo reinserts via discover mutation epoch, skip opposite optimistic likes on server merge, guard likes segment mid-flight switches, restore Places location bias semantics, extract popular suggestions helper, and add swipe-stack semantic actions for a11y. Align AGP to 8.11.1 with Kotlin 2.2.20. --- android/settings.gradle.kts | 3 +- lib/core/controllers/page_data_loader.dart | 56 ++-- lib/core/controllers/page_state_service.dart | 125 ++++++-- lib/core/data/models/popular_city.dart | 38 +++ lib/core/services/google_places_service.dart | 17 +- .../widgets/common/location_selector.dart | 23 +- .../widgets/property_swipe_stack.dart | 269 ++++++++++-------- .../controllers/likes_controller.dart | 22 +- .../views/location_search_view.dart | 45 ++- .../controllers/page_data_loader_test.dart | 16 ++ .../controllers/page_state_service_test.dart | 56 ++++ test/core/data/models/popular_city_test.dart | 8 + .../controllers/likes_controller_test.dart | 21 +- 13 files changed, 478 insertions(+), 221 deletions(-) diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 394ff8da..8b755825 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,7 +18,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.12.1" apply false + // AGP 8.11.1 is the max officially tested with Kotlin 2.2.20. + id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false // Google Services (Firebase) id("com.google.gms.google-services") version "4.4.2" apply false diff --git a/lib/core/controllers/page_data_loader.dart b/lib/core/controllers/page_data_loader.dart index a73d2d4e..3b08de13 100644 --- a/lib/core/controllers/page_data_loader.dart +++ b/lib/core/controllers/page_data_loader.dart @@ -292,32 +292,43 @@ class PageDataLoader { isLiked: isLikedSegment, ); - // Re-read state after the await โ€” optimistic likes may have been added - // while the request was in flight. - final latest = _pageState.getStateForPage(pageType); - final merged = _pageState.mergeLikesServerResults(resp.items, isLikedSegment: isLikedSegment); - - _pageState.updatePageState( - pageType, - latest.copyWith( - properties: merged, - selectedLocation: loc, - nextCursor: resp.nextCursor, - hasMore: resp.hasMorePages, - isLoading: false, - isRefreshing: false, - lastFetched: DateTime.now(), - error: null, - ), - ); - _pageState.syncLikesSegmentCacheFromVisible( + // Apply to the segment that was requested. If the user switched + // liked/passed mid-flight, only that segment's cache is updated โ€” the + // visible list for the new segment is left alone. + _pageState.applyLikesSegmentFetchResult( + isLikedSegment: isLikedSegment, + serverItems: resp.items, hasMore: resp.hasMorePages, nextCursor: resp.nextCursor, ); + // Keep selected location / error flags consistent when still on likes. + final latest = _pageState.getStateForPage(pageType); + final stillOnRequested = + ((latest.getAdditionalData('currentSegment') ?? 'liked') == 'liked') == + isLikedSegment; + if (stillOnRequested) { + _pageState.updatePageState( + pageType, + latest.copyWith( + selectedLocation: loc, + isLoading: false, + isRefreshing: false, + error: null, + ), + ); + } else if (latest.isLoading || latest.isRefreshing) { + // A newer load for the other segment owns loading flags. + } else { + _pageState.updatePageState( + pageType, + latest.copyWith(isLoading: false, isRefreshing: false, error: null), + ); + } return; } // Explore/Discover + final epochAtStart = pageType == PageType.discover ? _pageState.discoverMutationEpoch : 0; final resp = await _propertiesRepo.searchProperties( filters: state.filters.copyWith(searchQuery: state.searchQuery), latitude: loc.latitude, @@ -337,9 +348,14 @@ class PageDataLoader { // Re-read after await: swipes during the request already removed ids from // the local deck; also drop any session-swiped cards the API still returns. + // If undo reinserted a card while the request was in flight, preserve it. final latest = _pageState.getStateForPage(pageType); final items = pageType == PageType.discover - ? _pageState.filterOutSessionSwiped(resp.items) + ? _pageState.mergeDiscoverRefreshResults( + serverItems: resp.items, + localItems: latest.properties, + epochAtRequestStart: epochAtStart, + ) : resp.items; _pageState.updatePageState( diff --git a/lib/core/controllers/page_state_service.dart b/lib/core/controllers/page_state_service.dart index c2ee04bf..e3a0ec02 100644 --- a/lib/core/controllers/page_state_service.dart +++ b/lib/core/controllers/page_state_service.dart @@ -541,41 +541,47 @@ class PageStateService extends GetxController { // Swipe recording & optimistic mutations // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - Future recordSwipe({required int propertyId, required bool isLiked}) async { + /// Bumped whenever the Discover deck is mutated locally (swipe / undo / + /// remove). Loaders capture the value before network await so concurrent + /// mutations can be merged instead of overwritten by a stale response. + int _discoverMutationEpoch = 0; + + int get discoverMutationEpoch => _discoverMutationEpoch; + + void _bumpDiscoverMutation() => _discoverMutationEpoch++; + + /// [property] should be passed when the caller already holds the model + /// (e.g. Likes remove/move) so optimistic segment updates still work after + /// the card was removed from the visible list. + Future recordSwipe({ + required int propertyId, + required bool isLiked, + PropertyModel? property, + }) async { // Maintain liked/passed segment caches AND the visible list so the Likes // tab reflects Discover swipes immediately, regardless of which segment // is currently selected. - final prop = _findPropertyInAnyList(propertyId); + final prop = property ?? _findPropertyInAnyList(propertyId); if (isLiked) { if (prop != null) { - _trackOptimisticLike(prop); - _upsertLikesSegmentCache('liked', prop); - if (currentLikesSegment == 'liked') { - _prependToVisibleLikesList(prop); - } + // Single path for optimistic like + segment caches (shared with tests). + addPropertyToLikes(prop); } - _removeFromLikesSegmentCache('passed', propertyId); if (currentLikesSegment == 'passed') { removePropertyFromLikes(propertyId); } } else { // Pass: drop from liked, add to passed. if (prop != null) { - _trackOptimisticPass(prop); + addPropertyToPassed(prop); } else { // Still drop any pending like for this id. _optimisticLiked.remove(propertyId); + _removeFromLikesSegmentCache('liked', propertyId); } - _removeFromLikesSegmentCache('liked', propertyId); if (currentLikesSegment == 'liked') { removePropertyFromLikes(propertyId); } - if (prop != null) { - _upsertLikesSegmentCache('passed', prop); - if (currentLikesSegment == 'passed') { - _prependToVisibleLikesList(prop); - } - } } // Also remove from discover deck optimistically and remember the id so @@ -598,10 +604,18 @@ class PageStateService extends GetxController { for (final p in likesState.value.properties) { if (p.id == propertyId) return p; } - return null; + // Segment caches + optimistic maps (caller may have already removed the + // card from the visible list). + for (final cache in _likesSegmentCache.values) { + for (final p in cache.properties) { + if (p.id == propertyId) return p; + } + } + return _optimisticLiked[propertyId] ?? _optimisticPassed[propertyId]; } void removePropertyFromDiscover(int propertyId) { + _bumpDiscoverMutation(); final state = discoverState.value; final updatedList = state.properties.where((p) => p.id != propertyId).toList(); updatePageState(PageType.discover, state.copyWith(properties: updatedList)); @@ -633,6 +647,7 @@ class PageStateService extends GetxController { /// sees it again as the top card. void reinsertPropertyToDiscover(PropertyModel property) { _sessionSwipedPropertyIds.remove(property.id); + _bumpDiscoverMutation(); final state = discoverState.value; final exists = state.properties.any((p) => p.id == property.id); if (exists) return; @@ -640,6 +655,34 @@ class PageStateService extends GetxController { updatePageState(PageType.discover, state.copyWith(properties: updatedList)); } + /// Merges a Discover network page with the local deck when concurrent + /// mutations (swipe/undo) happened during the request. + /// + /// Local cards that are not session-swiped and missing from the server page + /// are preserved at the front (undo reinsert). Session-swiped ids stay out. + List mergeDiscoverRefreshResults({ + required List serverItems, + required List localItems, + required int epochAtRequestStart, + }) { + final filtered = filterOutSessionSwiped(serverItems); + if (discoverMutationEpoch == epochAtRequestStart) { + return filtered; + } + + final serverIds = filtered.map((p) => p.id).toSet(); + final preserve = localItems + .where((p) => !serverIds.contains(p.id) && !_sessionSwipedPropertyIds.contains(p.id)) + .toList(); + if (preserve.isEmpty) return filtered; + + DebugLogger.debug( + '๐Ÿ‘† Preserving ${preserve.length} local Discover card' + '${preserve.length == 1 ? '' : 's'} after concurrent mutation during fetch', + ); + return [...preserve, ...filtered]; + } + /// Reverses a previously-recorded swipe for the undo flow. Unlike /// [recordSwipe], this does NOT remove the property from the discover deck /// (it was just reinserted by [reinsertPropertyToDiscover]). It only @@ -722,6 +765,8 @@ class PageStateService extends GetxController { /// /// Pending items that the server now includes are cleared. Remaining /// pending items are prepended so a racey refresh cannot wipe them. + /// Server items still present in the *opposite* optimistic map are skipped + /// so a newer local reverse-swipe is not clobbered by a stale history page. List mergeLikesServerResults( List serverItems, { required bool isLikedSegment, @@ -732,9 +777,12 @@ class PageStateService extends GetxController { final merged = []; for (final p in serverItems) { + if (opposite.containsKey(p.id)) { + // Newer opposite local swipe wins over this stale server row. + continue; + } serverIds.add(p.id); optimistic.remove(p.id); - opposite.remove(p.id); merged.add(p); } @@ -748,6 +796,47 @@ class PageStateService extends GetxController { return [...pending, ...merged]; } + /// Applies a likes history fetch to the correct segment cache, and only + /// updates the visible list when that segment is still selected. + void applyLikesSegmentFetchResult({ + required bool isLikedSegment, + required List serverItems, + required bool hasMore, + String? nextCursor, + }) { + final segment = isLikedSegment ? 'liked' : 'passed'; + final merged = mergeLikesServerResults(serverItems, isLikedSegment: isLikedSegment); + final now = DateTime.now(); + _likesSegmentCache[segment] = _LikesSegmentCache( + properties: List.of(merged), + lastFetched: now, + hasMore: hasMore, + nextCursor: nextCursor, + ); + + if (currentLikesSegment != segment) { + DebugLogger.debug( + '๐Ÿ’– Discarded visible apply for stale likes segment=$segment ' + '(current=$currentLikesSegment); cache updated only', + ); + return; + } + + final latest = likesState.value; + updatePageState( + PageType.likes, + latest.copyWith( + properties: merged, + nextCursor: nextCursor, + hasMore: hasMore, + isLoading: false, + isRefreshing: false, + lastFetched: now, + error: null, + ), + ); + } + /// Snapshots the current likes list into the per-segment cache after a fetch. void syncLikesSegmentCacheFromVisible({required bool hasMore, String? nextCursor}) { final segment = currentLikesSegment; diff --git a/lib/core/data/models/popular_city.dart b/lib/core/data/models/popular_city.dart index 40829497..5f2eb90e 100644 --- a/lib/core/data/models/popular_city.dart +++ b/lib/core/data/models/popular_city.dart @@ -102,4 +102,42 @@ class PopularCity { } return merged; } + + /// Shared view-model for location pickers (modal + full-screen search). + static PopularSuggestionsList buildSuggestionsList(String query, List remote) { + final popularOnly = suggestionsForQuery(query); + final suggestions = mergeWithRemote(query, remote); + final showPopularHeader = popularOnly.isNotEmpty && (remote.isEmpty || query.trim().isEmpty); + return PopularSuggestionsList( + suggestions: suggestions, + popularOnly: popularOnly, + showPopularHeader: showPopularHeader, + ); + } +} + +/// Result of merging popular cities with remote autocomplete for a query. +class PopularSuggestionsList { + final List suggestions; + final List popularOnly; + final bool showPopularHeader; + + const PopularSuggestionsList({ + required this.suggestions, + required this.popularOnly, + required this.showPopularHeader, + }); + + bool get isEmpty => suggestions.isEmpty; + + int get listItemCount => suggestions.length + (showPopularHeader ? 1 : 0); + + /// Returns null when [index] is the popular-cities header row. + PlaceSuggestion? suggestionAt(int index) { + if (showPopularHeader) { + if (index == 0) return null; + return suggestions[index - 1]; + } + return suggestions[index]; + } } diff --git a/lib/core/services/google_places_service.dart b/lib/core/services/google_places_service.dart index e12c23f7..013b2720 100644 --- a/lib/core/services/google_places_service.dart +++ b/lib/core/services/google_places_service.dart @@ -207,24 +207,25 @@ class GooglePlacesService extends GetxService { } final countryCode = config.defaultCountry; + // No `types` restriction โ€” neighborhoods and localities both matter for + // property search; `(regions)` previously dropped neighborhood matches. final queryParams = { 'input': query, 'components': 'country:$countryCode', 'key': apiKey, - // Prefer geographies / cities over establishments for property search. - 'types': '(regions)', }; - // Soft location bias only โ€” never strictbounds. A tight radius + - // strictbounds previously hid distant cities (e.g. Gurgaon when the - // user is elsewhere). Country filter is enough for city/area search. - if (currentPosition != null && !config.placesStrictBounds) { + // Soft location bias ranks nearby results higher. Cap radius so distant + // metros in the same country still appear. `strictbounds` is only added + // when explicitly enabled via config (default false). + if (currentPosition != null) { queryParams['location'] = '${currentPosition.latitude},${currentPosition.longitude}'; - // Cap bias radius at 200km so nearby areas rank higher without - // excluding other metros in the same country. final configured = int.tryParse(config.placesRadiusMeters) ?? 25000; final biasMeters = configured.clamp(25000, 200000); queryParams['radius'] = '$biasMeters'; + if (config.placesStrictBounds) { + queryParams['strictbounds'] = 'true'; + } } final url = Uri.https( diff --git a/lib/core/widgets/common/location_selector.dart b/lib/core/widgets/common/location_selector.dart index 245835f9..59773496 100644 --- a/lib/core/widgets/common/location_selector.dart +++ b/lib/core/widgets/common/location_selector.dart @@ -324,12 +324,9 @@ class _LocationPickerModalState extends State { final isSearching = locationController.isSearchingPlaces.value; final query = _searchController.text.trim(); final hasQuery = query.isNotEmpty; - final suggestions = PopularCity.mergeWithRemote(query, remote); - final popularOnly = PopularCity.suggestionsForQuery(query); - final showPopularHeader = - popularOnly.isNotEmpty && (remote.isEmpty || !hasQuery); + final list = PopularCity.buildSuggestionsList(query, remote); - if (isSearching && suggestions.isEmpty) { + if (isSearching && list.isEmpty) { return const Center( child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(AppDesign.primaryYellow), @@ -337,10 +334,7 @@ class _LocationPickerModalState extends State { ); } - if (placesError.isNotEmpty && - suggestions.isEmpty && - hasQuery && - popularOnly.isEmpty) { + if (placesError.isNotEmpty && list.isEmpty && hasQuery) { return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), @@ -360,7 +354,7 @@ class _LocationPickerModalState extends State { ); } - if (suggestions.isEmpty && hasQuery) { + if (list.isEmpty && hasQuery) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -376,14 +370,15 @@ class _LocationPickerModalState extends State { ); } - if (suggestions.isEmpty) { + if (list.isEmpty) { return const SizedBox.shrink(); } return ListView.builder( - itemCount: suggestions.length + (showPopularHeader ? 1 : 0), + itemCount: list.listItemCount, itemBuilder: (context, index) { - if (showPopularHeader && index == 0) { + final suggestion = list.suggestionAt(index); + if (suggestion == null) { return Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Text( @@ -397,8 +392,6 @@ class _LocationPickerModalState extends State { ), ); } - final suggestionIndex = showPopularHeader ? index - 1 : index; - final suggestion = suggestions[suggestionIndex]; final isPopular = PopularCity.isPopularPlaceId(suggestion.placeId); return _buildLocationTile( title: suggestion.mainText, diff --git a/lib/features/discover/presentation/widgets/property_swipe_stack.dart b/lib/features/discover/presentation/widgets/property_swipe_stack.dart index 2261dab7..7fa6cfa7 100644 --- a/lib/features/discover/presentation/widgets/property_swipe_stack.dart +++ b/lib/features/discover/presentation/widgets/property_swipe_stack.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:get/get.dart'; @@ -42,8 +43,8 @@ class _SwipeDragState { /// - Gallery chevrons (hero) โ†’ change photo only /// - 360 interaction โ†’ block stack gestures via [onInteractionStart] /// -/// Like / Pass / Info action buttons were removed โ€” swipe gestures cover -/// those actions without cluttering the deck. +/// Like / Pass use horizontal drag. Semantic custom actions provide a +/// non-drag path for accessibility (screen readers / switch control). class PropertySwipeStack extends StatefulWidget { final List properties; final Function(PropertyModel) onSwipeLeft; @@ -277,6 +278,28 @@ class _PropertySwipeStackState extends State with TickerProv widget.onSwipeUp(_properties[0]); } + /// Programmatic like/pass for a11y (no drag required). + void _commitSwipe({required bool isRight}) { + if (_properties.isEmpty || _gesturesLocked) return; + final card = _properties[0]; + final width = MediaQuery.sizeOf(context).width; + _dragNotifier.value = _SwipeDragState( + position: Offset(isRight ? width : -width, 0), + rotation: isRight ? 0.35 : -0.35, + isDragging: false, + ); + if (isRight) { + _isSwipingRight = true; + _showSparkles = true; + _sparklesAnimationController.forward(); + widget.onSwipeRight(card); + } else { + widget.onSwipeLeft(card); + } + setState(() => _isExiting = true); + _swipeAnimationController.forward(); + } + @override Widget build(BuildContext context) { if (_properties.isEmpty) { @@ -304,134 +327,144 @@ class _PropertySwipeStackState extends State with TickerProv : MediaQuery.sizeOf(context).height) .clamp(1.0, 10000.0); - return GestureDetector( - onHorizontalDragStart: (details) { - if (_gesturesLocked) return; - _dragNotifier.value = _dragNotifier.value.copyWith(isDragging: true); - }, - onHorizontalDragUpdate: (details) { - if (_gesturesLocked) return; - final dx = details.primaryDelta ?? 0; - final newPos = Offset(_dragNotifier.value.position.dx + dx, 0); - _dragNotifier.value = _SwipeDragState( - position: newPos, - rotation: _calculateRotation(newPos, cardSize), - isDragging: true, - ); - }, - onHorizontalDragEnd: (details) { - if (_gesturesLocked && !_dragNotifier.value.isDragging) return; - if (_isExiting || _swipeAnimationController.isAnimating) return; - _handlePanEnd(details, cardSize); + final top = _properties[0]; + return Semantics( + label: top.title, + customSemanticsActions: { + CustomSemanticsAction(label: 'liked'.tr): () => _commitSwipe(isRight: true), + CustomSemanticsAction(label: 'passed'.tr): () => _commitSwipe(isRight: false), + CustomSemanticsAction(label: 'view_details'.tr): _openDetails, }, - child: Stack( - fit: StackFit.expand, - clipBehavior: Clip.hardEdge, - children: [ - // Background cards (static during drag โ€” no rebuild needed) - if (_properties.length > 1) - Positioned.fill( - child: Transform.scale( - scale: 0.95, - child: Opacity( - opacity: 0.8, - child: _buildBackgroundPreviewCard(_properties[1]), + child: GestureDetector( + onHorizontalDragStart: (details) { + if (_gesturesLocked) return; + _dragNotifier.value = _dragNotifier.value.copyWith(isDragging: true); + }, + onHorizontalDragUpdate: (details) { + if (_gesturesLocked) return; + final dx = details.primaryDelta ?? 0; + final newPos = Offset(_dragNotifier.value.position.dx + dx, 0); + _dragNotifier.value = _SwipeDragState( + position: newPos, + rotation: _calculateRotation(newPos, cardSize), + isDragging: true, + ); + }, + onHorizontalDragEnd: (details) { + if (_gesturesLocked && !_dragNotifier.value.isDragging) return; + if (_isExiting || _swipeAnimationController.isAnimating) return; + _handlePanEnd(details, cardSize); + }, + child: Stack( + fit: StackFit.expand, + clipBehavior: Clip.hardEdge, + children: [ + // Background cards (static during drag โ€” no rebuild needed) + if (_properties.length > 1) + Positioned.fill( + child: Transform.scale( + scale: 0.95, + child: Opacity( + opacity: 0.8, + child: _buildBackgroundPreviewCard(_properties[1]), + ), ), ), - ), - if (_properties.length > 2) - Positioned.fill( - child: Transform.scale( - scale: 0.9, - child: Opacity( - opacity: 0.6, - child: _buildBackgroundPreviewCard(_properties[2]), + if (_properties.length > 2) + Positioned.fill( + child: Transform.scale( + scale: 0.9, + child: Opacity( + opacity: 0.6, + child: _buildBackgroundPreviewCard(_properties[2]), + ), ), ), - ), - // Top card + end-of-scroll actions, with drag/swipe transform. - // AnimatedBuilder rebuilds only the transform wrapper during drag; - // the scroll deck is passed as `child` and rebuilt only on setState. - Positioned.fill( - child: AnimatedBuilder( - animation: _transformListenable, - child: _buildScrollableDeck(cardWidth: cardWidth, deckHeight: deckHeight), - builder: (context, cachedScroll) { - final drag = _dragNotifier.value; - - final swipeOffset = drag.isDragging - ? Offset(drag.position.dx, 0) - : Offset(drag.position.dx * (1 + _swipeAnimation.value * 2), 0); - - // Rotation "flick" โ€” extra rotation burst in last 20% of exit - final double flickMultiplier; - if (!drag.isDragging && _swipeAnimation.value > 0.8) { - final flickProgress = (_swipeAnimation.value - 0.8) / 0.2; - flickMultiplier = 1.0 + flickProgress * 0.3; - } else { - flickMultiplier = 1.0; - } - - final swipeRotation = drag.isDragging - ? drag.rotation - : drag.rotation * (1 + _swipeAnimation.value * 2) * flickMultiplier; - - final likeProgress = (drag.position.dx / dragThreshold).clamp(0.0, 1.0); - final passProgress = (-drag.position.dx / dragThreshold).clamp(0.0, 1.0); - final showFeedback = drag.isDragging && (likeProgress > 0 || passProgress > 0); - - // Card entrance scale (0.93โ†’1.0) when becoming top card - final entranceScale = _swipeAnimationController.isAnimating - ? 1.0 - : _entranceScale.value; - - return Transform.scale( - scale: entranceScale, - child: Transform.translate( - offset: swipeOffset, - child: Transform( - alignment: Alignment.bottomCenter, - transform: Matrix4.identity() - ..setEntry(3, 2, 0.001) - ..rotateZ(swipeRotation), - child: Opacity( - opacity: _swipeAnimationController.isAnimating - ? (1 - _swipeAnimation.value) - : 1.0, - child: Stack( - fit: StackFit.expand, - children: [ - // Fill the deck so scroll constraints stay bounded - // (avoids reassemble/layout hangs from loose stacks). - Positioned.fill(child: cachedScroll!), - if (showFeedback) - _buildSwipeFeedbackOverlay( - context, - likeProgress: likeProgress, - passProgress: passProgress, - ), - ], + // Top card + end-of-scroll actions, with drag/swipe transform. + // AnimatedBuilder rebuilds only the transform wrapper during drag; + // the scroll deck is passed as `child` and rebuilt only on setState. + Positioned.fill( + child: AnimatedBuilder( + animation: _transformListenable, + child: _buildScrollableDeck(cardWidth: cardWidth, deckHeight: deckHeight), + builder: (context, cachedScroll) { + final drag = _dragNotifier.value; + + final swipeOffset = drag.isDragging + ? Offset(drag.position.dx, 0) + : Offset(drag.position.dx * (1 + _swipeAnimation.value * 2), 0); + + // Rotation "flick" โ€” extra rotation burst in last 20% of exit + final double flickMultiplier; + if (!drag.isDragging && _swipeAnimation.value > 0.8) { + final flickProgress = (_swipeAnimation.value - 0.8) / 0.2; + flickMultiplier = 1.0 + flickProgress * 0.3; + } else { + flickMultiplier = 1.0; + } + + final swipeRotation = drag.isDragging + ? drag.rotation + : drag.rotation * (1 + _swipeAnimation.value * 2) * flickMultiplier; + + final likeProgress = (drag.position.dx / dragThreshold).clamp(0.0, 1.0); + final passProgress = (-drag.position.dx / dragThreshold).clamp(0.0, 1.0); + final showFeedback = + drag.isDragging && (likeProgress > 0 || passProgress > 0); + + // Card entrance scale (0.93โ†’1.0) when becoming top card + final entranceScale = _swipeAnimationController.isAnimating + ? 1.0 + : _entranceScale.value; + + return Transform.scale( + scale: entranceScale, + child: Transform.translate( + offset: swipeOffset, + child: Transform( + alignment: Alignment.bottomCenter, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateZ(swipeRotation), + child: Opacity( + opacity: _swipeAnimationController.isAnimating + ? (1 - _swipeAnimation.value) + : 1.0, + child: Stack( + fit: StackFit.expand, + children: [ + // Fill the deck so scroll constraints stay bounded + // (avoids reassemble/layout hangs from loose stacks). + Positioned.fill(child: cachedScroll!), + if (showFeedback) + _buildSwipeFeedbackOverlay( + context, + likeProgress: likeProgress, + passProgress: passProgress, + ), + ], + ), ), ), ), - ), - ); - }, - ), - ), - - // Sparkles animation - if (_showSparkles && _isSwipingRight) - Positioned.fill( - child: AnimatedBuilder( - animation: _sparklesAnimation, - builder: (context, child) { - return IgnorePointer(child: _SparklesWidget(animation: _sparklesAnimation)); + ); }, ), ), - ], + + // Sparkles animation + if (_showSparkles && _isSwipingRight) + Positioned.fill( + child: AnimatedBuilder( + animation: _sparklesAnimation, + builder: (context, child) { + return IgnorePointer(child: _SparklesWidget(animation: _sparklesAnimation)); + }, + ), + ), + ], + ), ), ); }, diff --git a/lib/features/likes/presentation/controllers/likes_controller.dart b/lib/features/likes/presentation/controllers/likes_controller.dart index a39e2938..2e329230 100644 --- a/lib/features/likes/presentation/controllers/likes_controller.dart +++ b/lib/features/likes/presentation/controllers/likes_controller.dart @@ -238,10 +238,13 @@ class LikesController extends GetxController { Future removeFromLikes(PropertyModel property) async { try { DebugLogger.api('๐Ÿ—‘๏ธ Removing property from likes: ${property.title}'); - // Optimistically update central page state - _pageStateService.removePropertyFromLikes(property.id); - // Record a "dislike" swipe to remove it from liked properties - await _pageStateService.recordSwipe(propertyId: property.id, isLiked: false); + // Pass the model so optimistic passed-cache updates still work after the + // card leaves the visible liked list. + await _pageStateService.recordSwipe( + propertyId: property.id, + isLiked: false, + property: property, + ); DebugLogger.success('โœ… Property successfully removed from likes'); @@ -264,10 +267,13 @@ class LikesController extends GetxController { Future moveToLikes(PropertyModel property) async { try { DebugLogger.api('โž• Moving property to likes: ${property.title}'); - // Optimistically remove from passed list - _pageStateService.removePropertyFromLikes(property.id); - // Record a "like" swipe to add it to liked properties - await _pageStateService.recordSwipe(propertyId: property.id, isLiked: true); + // Pass the model so optimistic liked-cache updates work after leaving + // the visible passed list. + await _pageStateService.recordSwipe( + propertyId: property.id, + isLiked: true, + property: property, + ); DebugLogger.success('โœ… Property successfully moved to likes'); diff --git a/lib/features/location_search/presentation/views/location_search_view.dart b/lib/features/location_search/presentation/views/location_search_view.dart index 3705b795..c49ab192 100644 --- a/lib/features/location_search/presentation/views/location_search_view.dart +++ b/lib/features/location_search/presentation/views/location_search_view.dart @@ -103,44 +103,37 @@ class LocationSearchView extends GetView { final locationController = Get.find(); return Obx(() { - if (controller.isLoading.value || locationController.isSearchingPlaces.value) { - final query = controller.searchQuery.value.trim(); - final remote = locationController.placeSuggestions.toList(growable: false); - final suggestions = PopularCity.mergeWithRemote(query, remote); - // Keep showing known results (incl. popular cities) while a network - // search is in flight instead of blanking the list. - if (suggestions.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - } + final query = controller.searchQuery.value.trim(); + final remote = locationController.placeSuggestions.toList(growable: false); + final list = PopularCity.buildSuggestionsList(query, remote); + final isSearching = controller.isLoading.value || locationController.isSearchingPlaces.value; - if (controller.searchError.value.isNotEmpty) { - final query = controller.searchQuery.value.trim(); - final popular = PopularCity.suggestionsForQuery(query); - if (popular.isEmpty) { - return _buildErrorState(context); - } + // Keep showing known results (incl. popular cities) while a network + // search is in flight instead of blanking the list. + if (isSearching && list.isEmpty) { + return const Center(child: CircularProgressIndicator()); } - final query = controller.searchQuery.value.trim(); - final remote = locationController.placeSuggestions.toList(growable: false); - final suggestions = PopularCity.mergeWithRemote(query, remote); - final popularOnly = PopularCity.suggestionsForQuery(query); - final showPopularHeader = popularOnly.isNotEmpty && (remote.isEmpty || query.isEmpty); + // Defensive: only full-screen error when there are no remote or popular + // suggestions left to show (mirrors location_selector). + if (controller.searchError.value.isNotEmpty && list.isEmpty && query.isNotEmpty) { + return _buildErrorState(context); + } - if (suggestions.isEmpty && query.isNotEmpty) { + if (list.isEmpty && query.isNotEmpty) { return _buildEmptyState(context); } - if (suggestions.isEmpty) { + if (list.isEmpty) { return _buildSearchPrompt(context); } return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: suggestions.length + (showPopularHeader ? 1 : 0), + itemCount: list.listItemCount, itemBuilder: (context, index) { - if (showPopularHeader && index == 0) { + final suggestion = list.suggestionAt(index); + if (suggestion == null) { return Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Text( @@ -152,8 +145,6 @@ class LocationSearchView extends GetView { ), ); } - final suggestionIndex = showPopularHeader ? index - 1 : index; - final suggestion = suggestions[suggestionIndex]; return _buildSuggestionTile(context, suggestion); }, ); diff --git a/test/core/controllers/page_data_loader_test.dart b/test/core/controllers/page_data_loader_test.dart index 688bf53b..a13154cd 100644 --- a/test/core/controllers/page_data_loader_test.dart +++ b/test/core/controllers/page_data_loader_test.dart @@ -62,9 +62,25 @@ void main() { when( () => pageState.filterOutSessionSwiped(any()), ).thenAnswer((inv) => List.from(inv.positionalArguments[0] as List)); + when(() => pageState.discoverMutationEpoch).thenReturn(0); + when( + () => pageState.mergeDiscoverRefreshResults( + serverItems: any(named: 'serverItems'), + localItems: any(named: 'localItems'), + epochAtRequestStart: any(named: 'epochAtRequestStart'), + ), + ).thenAnswer((inv) => List.from(inv.namedArguments[#serverItems] as List)); when( () => pageState.mergeLikesServerResults(any(), isLikedSegment: any(named: 'isLikedSegment')), ).thenAnswer((inv) => List.from(inv.positionalArguments[0] as List)); + when( + () => pageState.applyLikesSegmentFetchResult( + isLikedSegment: any(named: 'isLikedSegment'), + serverItems: any(named: 'serverItems'), + hasMore: any(named: 'hasMore'), + nextCursor: any(named: 'nextCursor'), + ), + ).thenReturn(null); when( () => pageState.syncLikesSegmentCacheFromVisible( hasMore: any(named: 'hasMore'), diff --git a/test/core/controllers/page_state_service_test.dart b/test/core/controllers/page_state_service_test.dart index 072da42f..099f4203 100644 --- a/test/core/controllers/page_state_service_test.dart +++ b/test/core/controllers/page_state_service_test.dart @@ -418,6 +418,62 @@ void main() { expect(merged3.map((p) => p.id), [501, 100]); }); + test('recordSwipe uses explicit property after visible list removal', () async { + final service = await createService(); + final prop = testPropertyModel(id: 606); + + service.updatePageState( + PageType.likes, + service.likesState.value.copyWith(properties: [prop]), + ); + // Caller removed from visible list first (legacy LikesController pattern). + service.removePropertyFromLikes(606); + + await service.recordSwipe(propertyId: 606, isLiked: false, property: prop); + + service.updateLikesSegment('passed'); + expect(service.likesState.value.properties.any((p) => p.id == 606), isTrue); + }); + + test('mergeLikesServerResults skips server rows with opposite optimistic swipe', () async { + final service = await createService(); + final prop = testPropertyModel(id: 707); + + service.updatePageState( + PageType.discover, + service.discoverState.value.copyWith(properties: [prop]), + ); + await service.recordSwipe(propertyId: 707, isLiked: false); + + // Liked history still has the property; opposite pass optimistic wins. + final merged = service.mergeLikesServerResults([prop], isLikedSegment: true); + expect(merged.any((p) => p.id == 707), isFalse); + }); + + test('mergeDiscoverRefreshResults preserves undo reinsert after concurrent fetch', () async { + final service = await createService(); + final a = testPropertyModel(id: 1); + final b = testPropertyModel(id: 2); + final reinserted = testPropertyModel(id: 3); + + final epoch = service.discoverMutationEpoch; + service.updatePageState( + PageType.discover, + service.discoverState.value.copyWith(properties: [a, b]), + ); + + // Simulate undo reinsert while a fetch (started at [epoch]) is in flight. + service.reinsertPropertyToDiscover(reinserted); + expect(service.discoverMutationEpoch, greaterThan(epoch)); + + final merged = service.mergeDiscoverRefreshResults( + serverItems: [a, b], + localItems: service.discoverState.value.properties, + epochAtRequestStart: epoch, + ); + expect(merged.map((p) => p.id).toList(), [3, 1, 2]); + }); + test('liked swipe finds property in explore list too', () async { final service = await createService(); final prop = testPropertyModel(id: 55); diff --git a/test/core/data/models/popular_city_test.dart b/test/core/data/models/popular_city_test.dart index 24f3dd36..4dd6dae6 100644 --- a/test/core/data/models/popular_city_test.dart +++ b/test/core/data/models/popular_city_test.dart @@ -65,5 +65,13 @@ void main() { expect(loc.latitude, city.latitude); expect(loc.longitude, city.longitude); }); + + test('buildSuggestionsList exposes header when remote empty', () { + final list = PopularCity.buildSuggestionsList('', const []); + expect(list.showPopularHeader, isTrue); + expect(list.suggestionAt(0), isNull); + expect(list.suggestionAt(1)?.mainText, isNotEmpty); + expect(list.listItemCount, PopularCity.defaults.length + 1); + }); }); } diff --git a/test/features/likes/presentation/controllers/likes_controller_test.dart b/test/features/likes/presentation/controllers/likes_controller_test.dart index 486ee94d..ce8b0785 100644 --- a/test/features/likes/presentation/controllers/likes_controller_test.dart +++ b/test/features/likes/presentation/controllers/likes_controller_test.dart @@ -46,6 +46,7 @@ void main() { () => mockPageStateService.recordSwipe( propertyId: any(named: 'propertyId'), isLiked: any(named: 'isLiked'), + property: any(named: 'property'), ), ).thenAnswer((_) async {}); when(() => mockPageStateService.updatePageSearch(any(), any())).thenReturn(null); @@ -314,7 +315,7 @@ void main() { }); group('LikesController โ€” property removal', () { - test('removeFromLikes calls removePropertyFromLikes and recordSwipe', () async { + test('removeFromLikes calls recordSwipe with property for optimistic cache', () async { final props = seedProperties(2); likesState.value = PageStateModel( pageType: PageType.likes, @@ -326,9 +327,12 @@ void main() { final controller = createController(); await controller.removeFromLikes(props[0]); - verify(() => mockPageStateService.removePropertyFromLikes(props[0].id)).called(1); verify( - () => mockPageStateService.recordSwipe(propertyId: props[0].id, isLiked: false), + () => mockPageStateService.recordSwipe( + propertyId: props[0].id, + isLiked: false, + property: props[0], + ), ).called(1); }); @@ -345,6 +349,7 @@ void main() { () => mockPageStateService.recordSwipe( propertyId: any(named: 'propertyId'), isLiked: any(named: 'isLiked'), + property: any(named: 'property'), ), ).thenThrow(ServerException('network error')); @@ -357,7 +362,7 @@ void main() { }); group('LikesController โ€” moveToLikes', () { - test('moveToLikes calls removePropertyFromLikes and recordSwipe with isLiked true', () async { + test('moveToLikes calls recordSwipe with isLiked true and property', () async { final props = seedProperties(2); likesState.value = PageStateModel( pageType: PageType.likes, @@ -369,9 +374,12 @@ void main() { final controller = createController(); await controller.moveToLikes(props[0]); - verify(() => mockPageStateService.removePropertyFromLikes(props[0].id)).called(1); verify( - () => mockPageStateService.recordSwipe(propertyId: props[0].id, isLiked: true), + () => mockPageStateService.recordSwipe( + propertyId: props[0].id, + isLiked: true, + property: props[0], + ), ).called(1); }); @@ -388,6 +396,7 @@ void main() { () => mockPageStateService.recordSwipe( propertyId: any(named: 'propertyId'), isLiked: any(named: 'isLiked'), + property: any(named: 'property'), ), ).thenThrow(ServerException('network error')); From 71ba692b207122c8905f311122949774667a16d8 Mon Sep 17 00:00:00 2001 From: Saksham Mittal Date: Thu, 16 Jul 2026 23:31:12 +0530 Subject: [PATCH 3/6] fix: tighten discover/likes race handling after PR review Preserve only undo-reinserted Discover cards across fetches, queue likes reloads when segment switches mid-flight, and align likes load-more with optimistic merge. Soften Places radius clamp and document Gradle heap overrides; add regression tests. --- android/gradle.properties | 14 +++- lib/core/controllers/page_data_loader.dart | 74 +++++++++++++++++-- lib/core/controllers/page_state_service.dart | 49 +++++++++--- lib/core/services/google_places_service.dart | 8 +- .../controllers/page_data_loader_test.dart | 60 +++++++++++++++ .../controllers/page_state_service_test.dart | 56 ++++++++++++++ test/core/data/models/popular_city_test.dart | 1 + 7 files changed, 238 insertions(+), 24 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index 3191e87d..471b5821 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,16 +1,22 @@ -# Conservative memory for Android builds (avoids daemon OOM on 8GB hosts while -# still covering AsmClassesTransform / mergeDebugGlobalSynthetics heap needs). -# Raise -Xmx / workers.max on high-RAM CI if builds are CPU-bound. +# Conservative defaults for 8GB developer machines (AsmClassesTransform / +# mergeDebugGlobalSynthetics + Kotlin daemon stay under typical laptop RAM). +# CI / high-RAM hosts: override without editing this file, e.g. +# export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m" +# or add to ~/.gradle/gradle.properties / CI secrets: +# org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m ... +# org.gradle.workers.max=2 +# org.gradle.parallel=true org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED # Prefer stability over max parallelism (large Flutter plugin graphs). +# Raise workers/parallel on CI only after raising -Xmx (see above). org.gradle.workers.max=1 org.gradle.parallel=false org.gradle.caching=true org.gradle.daemon=true org.gradle.configureondemand=false -# Kotlin compiler daemon (separate process). +# Kotlin compiler daemon (separate process; competes with Gradle -Xmx). kotlin.daemon.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m android.useAndroidX=true diff --git a/lib/core/controllers/page_data_loader.dart b/lib/core/controllers/page_data_loader.dart index 3b08de13..ba90f477 100644 --- a/lib/core/controllers/page_data_loader.dart +++ b/lib/core/controllers/page_data_loader.dart @@ -20,6 +20,11 @@ class PageDataLoader { final Set _activeLoads = {}; static const Duration _staleLoadingGuardWindow = Duration(seconds: 20); + /// When a likes load is already in flight and the user switches liked/passed + /// (or otherwise force-refreshes), [loadPageData] would early-return and + /// leave the new segment empty. Queue one follow-up load instead. + bool _pendingLikesReload = false; + // Debounce timers (per page) Timer? _exploreDebouncer; Timer? _discoverDebouncer; @@ -60,7 +65,17 @@ class PageDataLoader { _pageState.updatePageState(pageType, state); } - if (state.isLoading || state.isRefreshing || _activeLoads.contains(pageType)) return; + if (state.isLoading || state.isRefreshing || _activeLoads.contains(pageType)) { + // Segment switch / force refresh while likes is already fetching: do + // not drop the request โ€” reload once the in-flight call finishes. + // Only queue when a real in-flight load owns `_activeLoads`; orphaned + // isLoading flags alone would never call `_finishActiveLoad`. + if (pageType == PageType.likes && forceRefresh && _activeLoads.contains(pageType)) { + _pendingLikesReload = true; + DebugLogger.debug('๐Ÿ’– Queued likes reload while an in-flight likes fetch is active'); + } + return; + } final hasCached = state.properties.isNotEmpty; final isStale = state.isDataStale; @@ -94,8 +109,7 @@ class PageDataLoader { ); }) .whenComplete(() { - _activeLoads.remove(pageType); - _pageState.notifyPageRefreshing(pageType, false); + _finishActiveLoad(pageType); }), ); } else { @@ -119,12 +133,24 @@ class PageDataLoader { ); } finally { if (activeLoadRegistered && !launchedBackgroundLoad) { - _activeLoads.remove(pageType); - _pageState.notifyPageRefreshing(pageType, false); + _finishActiveLoad(pageType); } } } + void _finishActiveLoad(PageType pageType) { + _activeLoads.remove(pageType); + _pageState.notifyPageRefreshing(pageType, false); + if (pageType != PageType.likes || !_pendingLikesReload) return; + + _pendingLikesReload = false; + DebugLogger.debug('๐Ÿ’– Running queued likes reload after prior fetch completed'); + // Defer so we never re-enter loadPageData from inside finally/whenComplete. + scheduleMicrotask(() { + loadPageData(PageType.likes, forceRefresh: true); + }); + } + bool _shouldHealStaleLoadingState(PageType pageType, PageStateModel state) { if (_activeLoads.contains(pageType)) return false; @@ -180,16 +206,39 @@ class PageDataLoader { limit: 50, isLiked: isLikedSegment, ); - final newProperties = [...state.properties, ...response.items]; + // Re-read after await: concurrent remove/move/segment switch must not + // re-append removed rows or clobber the newly selected segment. + final latest = _pageState.getStateForPage(pageType); + final stillOnSegment = + ((latest.getAdditionalData('currentSegment') ?? 'liked') == 'liked') == + isLikedSegment; + if (!stillOnSegment) { + _pageState.updatePageState(pageType, latest.copyWith(isLoadingMore: false)); + return; + } + + // Absorb optimistic maps for this page (clear confirmed, skip opposite) + // then append only ids not already visible. + final pageMerged = _pageState.mergeLikesServerResults( + response.items, + isLikedSegment: isLikedSegment, + ); + final existingIds = latest.properties.map((p) => p.id).toSet(); + final toAppend = pageMerged.where((p) => !existingIds.contains(p.id)).toList(); + final newProperties = [...latest.properties, ...toAppend]; _pageState.updatePageState( pageType, - state.copyWith( + latest.copyWith( properties: newProperties, nextCursor: response.nextCursor, hasMore: response.hasMorePages, isLoadingMore: false, ), ); + _pageState.syncLikesSegmentCacheFromVisible( + hasMore: response.hasMorePages, + nextCursor: response.nextCursor, + ); } else { final response = await _propertiesRepo.searchProperties( filters: state.filters.copyWith(searchQuery: state.searchQuery), @@ -319,10 +368,21 @@ class PageDataLoader { } else if (latest.isLoading || latest.isRefreshing) { // A newer load for the other segment owns loading flags. } else { + // Stale segment finished after the user switched. Loading flags are + // already false (resetData on segment switch). Queue a reload for the + // *current* segment if the visible list is still empty and no follow-up + // load was already requested via forceRefresh. _pageState.updatePageState( pageType, latest.copyWith(isLoading: false, isRefreshing: false, error: null), ); + if (latest.properties.isEmpty && !_pendingLikesReload) { + _pendingLikesReload = true; + DebugLogger.debug( + '๐Ÿ’– Stale likes segment apply left empty list; queuing reload for ' + '${latest.getAdditionalData('currentSegment') ?? 'liked'}', + ); + } } return; } diff --git a/lib/core/controllers/page_state_service.dart b/lib/core/controllers/page_state_service.dart index e3a0ec02..f4d8b0d3 100644 --- a/lib/core/controllers/page_state_service.dart +++ b/lib/core/controllers/page_state_service.dart @@ -134,6 +134,7 @@ class PageStateService extends GetxController { _optimisticLiked.clear(); _optimisticPassed.clear(); _sessionSwipedPropertyIds.clear(); + _discoverPreserveIds.clear(); try { _storage.remove(_exploreStateStorageKey); @@ -615,6 +616,7 @@ class PageStateService extends GetxController { } void removePropertyFromDiscover(int propertyId) { + _discoverPreserveIds.remove(propertyId); _bumpDiscoverMutation(); final state = discoverState.value; final updatedList = state.properties.where((p) => p.id != propertyId).toList(); @@ -626,6 +628,12 @@ class PageStateService extends GetxController { /// races before `exclude_swiped` is reflected server-side. final Set _sessionSwipedPropertyIds = {}; + /// Property ids explicitly reinserted by undo. [mergeDiscoverRefreshResults] + /// only preserves these local-only cards across an in-flight fetch โ€” never + /// the whole remaining deck (which would glue old location/filter cards onto + /// a new server page after a mid-refresh swipe). + final Set _discoverPreserveIds = {}; + /// Filters [items] to drop any property swiped earlier in this session. List filterOutSessionSwiped(List items) { if (_sessionSwipedPropertyIds.isEmpty || items.isEmpty) return items; @@ -647,6 +655,7 @@ class PageStateService extends GetxController { /// sees it again as the top card. void reinsertPropertyToDiscover(PropertyModel property) { _sessionSwipedPropertyIds.remove(property.id); + _discoverPreserveIds.add(property.id); _bumpDiscoverMutation(); final state = discoverState.value; final exists = state.properties.any((p) => p.id == property.id); @@ -658,27 +667,47 @@ class PageStateService extends GetxController { /// Merges a Discover network page with the local deck when concurrent /// mutations (swipe/undo) happened during the request. /// - /// Local cards that are not session-swiped and missing from the server page - /// are preserved at the front (undo reinsert). Session-swiped ids stay out. + /// Only cards explicitly reinserted via [reinsertPropertyToDiscover] are + /// kept when missing from the server page โ€” and they are kept whether the + /// undo happened mid-flight or just before the request started. A plain + /// swipe must never preserve the rest of the pre-refresh deck (that would + /// glue old location/filter cards onto a new server page). Session-swiped + /// ids always stay out. + /// + /// [epochAtRequestStart] is retained for call-site compatibility and debug + /// context; preserve eligibility is driven solely by [_discoverPreserveIds]. List mergeDiscoverRefreshResults({ required List serverItems, required List localItems, required int epochAtRequestStart, }) { final filtered = filterOutSessionSwiped(serverItems); - if (discoverMutationEpoch == epochAtRequestStart) { - return filtered; + final serverIds = filtered.map((p) => p.id).toSet(); + + // Always honor undo-reinsert markers (not only when epoch advanced during + // the request). Epoch alone cannot distinguish "undo before fetch" from + // "no local cards to keep". + final preserve = []; + if (_discoverPreserveIds.isNotEmpty) { + for (final p in localItems) { + if (!_discoverPreserveIds.contains(p.id)) continue; + if (serverIds.contains(p.id)) continue; + if (_sessionSwipedPropertyIds.contains(p.id)) continue; + preserve.add(p); + } } - final serverIds = filtered.map((p) => p.id).toSet(); - final preserve = localItems - .where((p) => !serverIds.contains(p.id) && !_sessionSwipedPropertyIds.contains(p.id)) - .toList(); + // Consume markers after this merge so a later page does not keep resurrecting + // cards that were already applied (or that the server now owns). + _discoverPreserveIds.clear(); + if (preserve.isEmpty) return filtered; + final concurrent = discoverMutationEpoch != epochAtRequestStart; DebugLogger.debug( - '๐Ÿ‘† Preserving ${preserve.length} local Discover card' - '${preserve.length == 1 ? '' : 's'} after concurrent mutation during fetch', + '๐Ÿ‘† Preserving ${preserve.length} undo-reinserted Discover card' + '${preserve.length == 1 ? '' : 's'} after fetch merge' + '${concurrent ? ' (concurrent mutation)' : ''}', ); return [...preserve, ...filtered]; } diff --git a/lib/core/services/google_places_service.dart b/lib/core/services/google_places_service.dart index 013b2720..4f8fe8d4 100644 --- a/lib/core/services/google_places_service.dart +++ b/lib/core/services/google_places_service.dart @@ -216,12 +216,14 @@ class GooglePlacesService extends GetxService { }; // Soft location bias ranks nearby results higher. Cap radius so distant - // metros in the same country still appear. `strictbounds` is only added - // when explicitly enabled via config (default false). + // metros in the same country still appear. Do not floor below the + // configured value โ€” tighter `PLACES_RADIUS_METERS` must still apply. + // `strictbounds` is only added when explicitly enabled via config + // (default false) so soft bias is never a hard geo trap. if (currentPosition != null) { queryParams['location'] = '${currentPosition.latitude},${currentPosition.longitude}'; final configured = int.tryParse(config.placesRadiusMeters) ?? 25000; - final biasMeters = configured.clamp(25000, 200000); + final biasMeters = configured.clamp(1, 200000); queryParams['radius'] = '$biasMeters'; if (config.placesStrictBounds) { queryParams['strictbounds'] = 'true'; diff --git a/test/core/controllers/page_data_loader_test.dart b/test/core/controllers/page_data_loader_test.dart index a13154cd..f9d5057d 100644 --- a/test/core/controllers/page_data_loader_test.dart +++ b/test/core/controllers/page_data_loader_test.dart @@ -6,6 +6,8 @@ // [PageStateModel] is seeded via the page-state mock so the loader's branching // on cached data, staleness and loading flags is exercised end-to-end. +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:get/get.dart'; import 'package:ghar360/core/controllers/page_data_loader.dart'; @@ -618,6 +620,64 @@ void main() { isLiked: any(named: 'isLiked'), ), ).called(1); + verify( + () => + pageState.mergeLikesServerResults(any(), isLikedSegment: any(named: 'isLikedSegment')), + ).called(1); + verify( + () => pageState.syncLikesSegmentCacheFromVisible( + hasMore: any(named: 'hasMore'), + nextCursor: any(named: 'nextCursor'), + ), + ).called(1); + }); + + test('queues forceRefresh likes load while an in-flight likes fetch is active', () async { + final gate = Completer(); + var calls = 0; + when( + () => swipesRepo.getSwipeHistoryProperties( + filters: any(named: 'filters'), + latitude: any(named: 'latitude'), + longitude: any(named: 'longitude'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + isLiked: any(named: 'isLiked'), + ), + ).thenAnswer((_) async { + calls++; + if (calls == 1) { + return gate.future; + } + return UnifiedPropertyResponse( + items: [testPropertyModel(id: 99)], + nextCursor: null, + hasMore: false, + ); + }); + + // Foreground load (empty cache). + final first = loader.loadPageData(PageType.likes); + // Allow the first fetch to register as active. + await Future.delayed(Duration.zero); + + // Segment switch / force refresh while first fetch is still pending. + await loader.loadPageData(PageType.likes, forceRefresh: true); + expect(calls, 1); // second call blocked until first completes + + gate.complete( + UnifiedPropertyResponse( + items: [testPropertyModel(id: 1)], + nextCursor: null, + hasMore: false, + ), + ); + await first; + // Queued reload runs on a microtask after _finishActiveLoad. + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(calls, 2); }); test('skips when isLoading is true', () async { diff --git a/test/core/controllers/page_state_service_test.dart b/test/core/controllers/page_state_service_test.dart index 099f4203..054c7066 100644 --- a/test/core/controllers/page_state_service_test.dart +++ b/test/core/controllers/page_state_service_test.dart @@ -474,6 +474,62 @@ void main() { expect(merged.map((p) => p.id).toList(), [3, 1, 2]); }); + test( + 'mergeDiscoverRefreshResults does not keep old-query cards after mid-refresh swipe', + () async { + final service = await createService(); + final a = testPropertyModel(id: 1); + final b = testPropertyModel(id: 2); + final c = testPropertyModel(id: 3); + final d = testPropertyModel(id: 4); + final e = testPropertyModel(id: 5); + + final epoch = service.discoverMutationEpoch; + // Pre-refresh deck for location A. + service.updatePageState( + PageType.discover, + service.discoverState.value.copyWith(properties: [a, b, c]), + ); + + // User swipes A away while a location-change fetch is in flight. + // Epoch bumps, but only undo reinserts should be preserved โ€” not B/C. + await service.recordSwipe(propertyId: 1, isLiked: true); + expect(service.discoverMutationEpoch, greaterThan(epoch)); + expect(service.discoverState.value.properties.map((p) => p.id), [2, 3]); + + // Server returns the new location's first page. + final merged = service.mergeDiscoverRefreshResults( + serverItems: [d, e], + localItems: service.discoverState.value.properties, + epochAtRequestStart: epoch, + ); + expect(merged.map((p) => p.id).toList(), [4, 5]); + }, + ); + + test('mergeDiscoverRefreshResults keeps undo reinsert even when epoch matches', () async { + final service = await createService(); + final a = testPropertyModel(id: 1); + final b = testPropertyModel(id: 2); + final reinserted = testPropertyModel(id: 3); + + // Undo happens before the fetch starts, so epoch at request start + // equals the post-undo epoch. Preserve markers must still win. + service.updatePageState( + PageType.discover, + service.discoverState.value.copyWith(properties: [a, b]), + ); + service.reinsertPropertyToDiscover(reinserted); + final epoch = service.discoverMutationEpoch; + + final merged = service.mergeDiscoverRefreshResults( + serverItems: [a, b], + localItems: service.discoverState.value.properties, + epochAtRequestStart: epoch, + ); + expect(merged.map((p) => p.id).toList(), [3, 1, 2]); + }); + test('liked swipe finds property in explore list too', () async { final service = await createService(); final prop = testPropertyModel(id: 55); diff --git a/test/core/data/models/popular_city_test.dart b/test/core/data/models/popular_city_test.dart index 4dd6dae6..49db0214 100644 --- a/test/core/data/models/popular_city_test.dart +++ b/test/core/data/models/popular_city_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; + import 'package:ghar360/core/data/models/popular_city.dart'; import 'package:ghar360/core/services/google_places_service.dart'; From 584630f75a40d2051aa332d92919598b30504a04 Mon Sep 17 00:00:00 2001 From: Saksham Mittal Date: Thu, 16 Jul 2026 23:39:07 +0530 Subject: [PATCH 4/6] fix: address remaining PR review threads Stop remote self-dedupe in popular city merge, animate a11y swipes from the drag threshold via a shared exit helper, use currentLikesSegment in the data loader, and drop the unused popularOnly list field. --- lib/core/controllers/page_data_loader.dart | 18 ++++------- lib/core/data/models/popular_city.dart | 32 +++++++------------ .../widgets/property_swipe_stack.dart | 29 +++++++++-------- .../controllers/page_data_loader_test.dart | 1 + test/core/data/models/popular_city_test.dart | 24 +++++++++++++- 5 files changed, 58 insertions(+), 46 deletions(-) diff --git a/lib/core/controllers/page_data_loader.dart b/lib/core/controllers/page_data_loader.dart index ba90f477..d90ab20e 100644 --- a/lib/core/controllers/page_data_loader.dart +++ b/lib/core/controllers/page_data_loader.dart @@ -196,8 +196,7 @@ class PageDataLoader { } if (pageType == PageType.likes) { - final isLikedSegment = - (state.getAdditionalData('currentSegment') ?? 'liked') == 'liked'; + final isLikedSegment = _pageState.currentLikesSegment == 'liked'; final response = await _swipesRepo.getSwipeHistoryProperties( filters: state.filters.copyWith(searchQuery: state.searchQuery), latitude: loc.latitude, @@ -209,9 +208,7 @@ class PageDataLoader { // Re-read after await: concurrent remove/move/segment switch must not // re-append removed rows or clobber the newly selected segment. final latest = _pageState.getStateForPage(pageType); - final stillOnSegment = - ((latest.getAdditionalData('currentSegment') ?? 'liked') == 'liked') == - isLikedSegment; + final stillOnSegment = (_pageState.currentLikesSegment == 'liked') == isLikedSegment; if (!stillOnSegment) { _pageState.updatePageState(pageType, latest.copyWith(isLoadingMore: false)); return; @@ -330,8 +327,9 @@ class PageDataLoader { ); if (pageType == PageType.likes) { - final isLikedSegment = - (state.getAdditionalData('currentSegment') ?? 'liked') == 'liked'; + // Capture segment at request start via the shared getter so checks stay + // aligned with [PageStateService.applyLikesSegmentFetchResult]. + final isLikedSegment = _pageState.currentLikesSegment == 'liked'; final resp = await _swipesRepo.getSwipeHistoryProperties( filters: state.filters.copyWith(searchQuery: state.searchQuery), latitude: loc.latitude, @@ -352,9 +350,7 @@ class PageDataLoader { ); // Keep selected location / error flags consistent when still on likes. final latest = _pageState.getStateForPage(pageType); - final stillOnRequested = - ((latest.getAdditionalData('currentSegment') ?? 'liked') == 'liked') == - isLikedSegment; + final stillOnRequested = (_pageState.currentLikesSegment == 'liked') == isLikedSegment; if (stillOnRequested) { _pageState.updatePageState( pageType, @@ -380,7 +376,7 @@ class PageDataLoader { _pendingLikesReload = true; DebugLogger.debug( '๐Ÿ’– Stale likes segment apply left empty list; queuing reload for ' - '${latest.getAdditionalData('currentSegment') ?? 'liked'}', + '${_pageState.currentLikesSegment}', ); } } diff --git a/lib/core/data/models/popular_city.dart b/lib/core/data/models/popular_city.dart index 5f2eb90e..519eb3e8 100644 --- a/lib/core/data/models/popular_city.dart +++ b/lib/core/data/models/popular_city.dart @@ -82,22 +82,23 @@ class PopularCity { return matching(query).map((c) => c.toPlaceSuggestion()).toList(growable: false); } - /// Merges popular matches ahead of remote suggestions, de-duplicating by - /// exact case-insensitive main text only. + /// Merges popular matches ahead of remote suggestions, de-duplicating remote + /// rows that collide with a popular city by exact case-insensitive main text. /// - /// Do not use substring matching on main text/description โ€” that drops - /// legitimate areas like "Greater Noida" when popular "Noida" is present. + /// Remote rows are **not** de-duplicated against each other โ€” two Places + /// results can share a neighborhood-style `mainText` in different cities. + /// Do not use substring matching โ€” that drops areas like "Greater Noida" + /// when popular "Noida" is present. static List mergeWithRemote(String query, List remote) { final popular = suggestionsForQuery(query); if (popular.isEmpty) return remote; - final seen = {for (final p in popular) p.mainText.trim().toLowerCase()}; + final popularKeys = {for (final p in popular) p.mainText.trim().toLowerCase()}; final merged = [...popular]; for (final r in remote) { final key = r.mainText.trim().toLowerCase(); - if (key.isEmpty || seen.contains(key)) continue; - seen.add(key); + if (key.isEmpty || popularKeys.contains(key)) continue; merged.add(r); } return merged; @@ -105,28 +106,19 @@ class PopularCity { /// Shared view-model for location pickers (modal + full-screen search). static PopularSuggestionsList buildSuggestionsList(String query, List remote) { - final popularOnly = suggestionsForQuery(query); + final popularCount = matching(query).length; final suggestions = mergeWithRemote(query, remote); - final showPopularHeader = popularOnly.isNotEmpty && (remote.isEmpty || query.trim().isEmpty); - return PopularSuggestionsList( - suggestions: suggestions, - popularOnly: popularOnly, - showPopularHeader: showPopularHeader, - ); + final showPopularHeader = popularCount > 0 && (remote.isEmpty || query.trim().isEmpty); + return PopularSuggestionsList(suggestions: suggestions, showPopularHeader: showPopularHeader); } } /// Result of merging popular cities with remote autocomplete for a query. class PopularSuggestionsList { final List suggestions; - final List popularOnly; final bool showPopularHeader; - const PopularSuggestionsList({ - required this.suggestions, - required this.popularOnly, - required this.showPopularHeader, - }); + const PopularSuggestionsList({required this.suggestions, required this.showPopularHeader}); bool get isEmpty => suggestions.isEmpty; diff --git a/lib/features/discover/presentation/widgets/property_swipe_stack.dart b/lib/features/discover/presentation/widgets/property_swipe_stack.dart index 7fa6cfa7..8424e82d 100644 --- a/lib/features/discover/presentation/widgets/property_swipe_stack.dart +++ b/lib/features/discover/presentation/widgets/property_swipe_stack.dart @@ -223,17 +223,8 @@ class _PropertySwipeStackState extends State with TickerProv } else { isRight = drag.rotation >= 0; } - if (isRight) { - _isSwipingRight = true; - _showSparkles = true; - _sparklesAnimationController.forward(); - widget.onSwipeRight(_properties[0]); - } else { - widget.onSwipeLeft(_properties[0]); - } - // setState once to add sparkles / hide action buttons - setState(() => _isExiting = true); - _swipeAnimationController.forward(); + // Keep the live drag position; shared exit path fires callbacks + anim. + _beginExitSwipe(isRight: isRight); } else { _snapBack(); } @@ -279,15 +270,25 @@ class _PropertySwipeStackState extends State with TickerProv } /// Programmatic like/pass for a11y (no drag required). + /// + /// Starts the card near the drag-commit threshold (~0.3ร— width) so the exit + /// animation still slides off-screen, matching gesture-driven swipes. A full + /// width seed would place the card already off-screen at animValue=0. void _commitSwipe({required bool isRight}) { if (_properties.isEmpty || _gesturesLocked) return; - final card = _properties[0]; final width = MediaQuery.sizeOf(context).width; + final startX = width * 0.3; _dragNotifier.value = _SwipeDragState( - position: Offset(isRight ? width : -width, 0), - rotation: isRight ? 0.35 : -0.35, + position: Offset(isRight ? startX : -startX, 0), + rotation: isRight ? 0.15 : -0.15, isDragging: false, ); + _beginExitSwipe(isRight: isRight); + } + + /// Shared exit for gesture-commit and a11y-commit paths. + void _beginExitSwipe({required bool isRight}) { + final card = _properties[0]; if (isRight) { _isSwipingRight = true; _showSparkles = true; diff --git a/test/core/controllers/page_data_loader_test.dart b/test/core/controllers/page_data_loader_test.dart index f9d5057d..9085569c 100644 --- a/test/core/controllers/page_data_loader_test.dart +++ b/test/core/controllers/page_data_loader_test.dart @@ -89,6 +89,7 @@ void main() { nextCursor: any(named: 'nextCursor'), ), ).thenReturn(null); + when(() => pageState.currentLikesSegment).thenReturn('liked'); // LocationController stubs. when( diff --git a/test/core/data/models/popular_city_test.dart b/test/core/data/models/popular_city_test.dart index 49db0214..22d40060 100644 --- a/test/core/data/models/popular_city_test.dart +++ b/test/core/data/models/popular_city_test.dart @@ -53,12 +53,34 @@ void main() { final merged = PopularCity.mergeWithRemote('noi', remote); expect(merged.first.mainText, 'Noida'); expect(PopularCity.isPopularPlaceId(merged.first.placeId), isTrue); - // Exact mainText de-dupe only โ€” not substring matches. + // Exact mainText de-dupe only vs popular โ€” not substring matches. expect(merged.where((s) => s.mainText == 'Noida'), hasLength(1)); expect(merged.any((s) => s.mainText == 'Sector 18'), isTrue); expect(merged.any((s) => s.mainText == 'Greater Noida'), isTrue); }); + test('mergeWithRemote keeps distinct remotes that share mainText', () { + final remote = [ + PlaceSuggestion( + placeId: 'google-a', + description: 'Green Park, Delhi', + mainText: 'Green Park', + secondaryText: 'Delhi', + ), + PlaceSuggestion( + placeId: 'google-b', + description: 'Green Park, Gurgaon', + mainText: 'Green Park', + secondaryText: 'Gurgaon', + ), + ]; + + // No popular-city collision with "Green Park". + final merged = PopularCity.mergeWithRemote('green', remote); + expect(merged.where((s) => s.mainText == 'Green Park'), hasLength(2)); + expect(merged.map((s) => s.placeId), containsAll(['google-a', 'google-b'])); + }); + test('toLocationData exposes coordinates', () { final city = PopularCity.defaults.first; final loc = city.toLocationData(); From f493c3897ad2a4e837a8d92fa82e9123be45a8d4 Mon Sep 17 00:00:00 2001 From: Saksham Mittal Date: Thu, 16 Jul 2026 23:41:34 +0530 Subject: [PATCH 5/6] fix: add page load generation guards and dispose-safe reloads Invalidate in-flight first-page and load-more completions with a per-page generation counter, queue force reloads for all tabs when superseded, and skip queued microtasks after dispose. --- lib/core/controllers/page_data_loader.dart | 126 ++++++++++++++---- .../controllers/page_data_loader_test.dart | 36 +++++ 2 files changed, 139 insertions(+), 23 deletions(-) diff --git a/lib/core/controllers/page_data_loader.dart b/lib/core/controllers/page_data_loader.dart index d90ab20e..8a5ef885 100644 --- a/lib/core/controllers/page_data_loader.dart +++ b/lib/core/controllers/page_data_loader.dart @@ -20,10 +20,19 @@ class PageDataLoader { final Set _activeLoads = {}; static const Duration _staleLoadingGuardWindow = Duration(seconds: 20); - /// When a likes load is already in flight and the user switches liked/passed - /// (or otherwise force-refreshes), [loadPageData] would early-return and - /// leave the new segment empty. Queue one follow-up load instead. - bool _pendingLikesReload = false; + /// Per-page request generation. Bumped when a newer load supersedes an + /// in-flight one so stale completions never mutate current UI/cache state. + final Map _requestGeneration = { + PageType.explore: 0, + PageType.discover: 0, + PageType.likes: 0, + }; + + /// Pages that should force-reload once the current in-flight first-page + /// fetch finishes (segment switch, filter change, pull-to-refresh). + final Set _pendingForceReload = {}; + + bool _disposed = false; // Debounce timers (per page) Timer? _exploreDebouncer; @@ -37,16 +46,29 @@ class PageDataLoader { PageDataLoader(this._pageState, this._propertiesRepo, this._swipesRepo, this._locationController); void dispose() { + _disposed = true; + _pendingForceReload.clear(); _exploreDebouncer?.cancel(); _discoverDebouncer?.cancel(); _likesDebouncer?.cancel(); } + int _bumpGeneration(PageType pageType) { + final next = (_requestGeneration[pageType] ?? 0) + 1; + _requestGeneration[pageType] = next; + return next; + } + + bool _isCurrentGeneration(PageType pageType, int generation) => + !_disposed && (_requestGeneration[pageType] ?? 0) == generation; + Future loadPageData( PageType pageType, { bool forceRefresh = false, bool backgroundRefresh = false, }) async { + if (_disposed) return; + bool activeLoadRegistered = false; bool launchedBackgroundLoad = false; try { @@ -66,13 +88,14 @@ class PageDataLoader { } if (state.isLoading || state.isRefreshing || _activeLoads.contains(pageType)) { - // Segment switch / force refresh while likes is already fetching: do - // not drop the request โ€” reload once the in-flight call finishes. - // Only queue when a real in-flight load owns `_activeLoads`; orphaned - // isLoading flags alone would never call `_finishActiveLoad`. - if (pageType == PageType.likes && forceRefresh && _activeLoads.contains(pageType)) { - _pendingLikesReload = true; - DebugLogger.debug('๐Ÿ’– Queued likes reload while an in-flight likes fetch is active'); + // Newer force refresh while a first-page fetch is in flight: invalidate + // the in-flight completion and queue one follow-up load. + if (forceRefresh && _activeLoads.contains(pageType)) { + _bumpGeneration(pageType); + _pendingForceReload.add(pageType); + DebugLogger.debug( + '๐Ÿ” Queued ${pageType.name} force reload while an in-flight fetch is active', + ); } return; } @@ -82,28 +105,32 @@ class PageDataLoader { // If there's no cached data at all, do a foreground load if (!hasCached) { + final generation = _bumpGeneration(pageType); _activeLoads.add(pageType); activeLoadRegistered = true; _pageState.updatePageState(pageType, state.copyWith(isLoading: true, error: null)); - await _fetchAndUpdatePage(pageType); + await _fetchAndUpdatePage(pageType, generation: generation); } else { // We have cached data: return immediately and revalidate in // background when asked or stale if (forceRefresh || backgroundRefresh || isStale) { + final generation = _bumpGeneration(pageType); _activeLoads.add(pageType); activeLoadRegistered = true; launchedBackgroundLoad = true; _pageState.notifyPageRefreshing(pageType, true); _pageState.updatePageState(pageType, state.copyWith(isRefreshing: true, error: null)); unawaited( - _fetchAndUpdatePage(pageType) + _fetchAndUpdatePage(pageType, generation: generation) .catchError((e, stackTrace) { + if (!_isCurrentGeneration(pageType, generation)) return; DebugLogger.error('โŒ Background refresh failed for ${pageType.name}', e); final current = _pageState.getStateForPage(pageType); _pageState.updatePageState( pageType, current.copyWith( isRefreshing: false, + isLoadingMore: false, error: ErrorMapper.mapApiError(e, stackTrace), ), ); @@ -118,9 +145,11 @@ class PageDataLoader { } } + if (_disposed) return; final updatedCount = _pageState.getStateForPage(pageType).properties.length; DebugLogger.success('โœ… Loaded $updatedCount properties for ${pageType.name}'); } catch (e, stackTrace) { + if (_disposed) return; DebugLogger.error('โŒ Failed to load ${pageType.name} data', e, stackTrace); final state = _pageState.getStateForPage(pageType); _pageState.updatePageState( @@ -128,6 +157,7 @@ class PageDataLoader { state.copyWith( isLoading: false, isRefreshing: false, + isLoadingMore: false, error: ErrorMapper.mapApiError(e, stackTrace), ), ); @@ -140,14 +170,24 @@ class PageDataLoader { void _finishActiveLoad(PageType pageType) { _activeLoads.remove(pageType); + if (_disposed) return; + _pageState.notifyPageRefreshing(pageType, false); - if (pageType != PageType.likes || !_pendingLikesReload) return; + // If the in-flight fetch was invalidated (generation bumped) it may have + // returned without clearing loading flags โ€” heal them so a queued reload + // is not blocked by isLoading/isRefreshing. + final state = _pageState.getStateForPage(pageType); + if (state.isLoading || state.isRefreshing) { + _pageState.updatePageState(pageType, state.copyWith(isLoading: false, isRefreshing: false)); + } - _pendingLikesReload = false; - DebugLogger.debug('๐Ÿ’– Running queued likes reload after prior fetch completed'); + if (!_pendingForceReload.remove(pageType)) return; + + DebugLogger.debug('๐Ÿ” Running queued ${pageType.name} reload after prior fetch completed'); // Defer so we never re-enter loadPageData from inside finally/whenComplete. scheduleMicrotask(() { - loadPageData(PageType.likes, forceRefresh: true); + if (_disposed) return; + loadPageData(pageType, forceRefresh: true); }); } @@ -167,10 +207,14 @@ class PageDataLoader { } Future loadMorePageData(PageType pageType) async { + if (_disposed) return; try { final state = _pageState.getStateForPage(pageType); if (state.isLoading || state.isLoadingMore || !state.hasMore) return; + // Capture generation without bumping so a concurrent force refresh + // (which bumps) invalidates this append. + final generation = _requestGeneration[pageType] ?? 0; _pageState.updatePageState(pageType, state.copyWith(isLoadingMore: true)); final loc = state.selectedLocation; @@ -179,7 +223,9 @@ class PageDataLoader { 'โš ๏ธ No location set for ${pageType.name} while loading more. ' 'Skipping.', ); - _pageState.updatePageState(pageType, state.copyWith(isLoadingMore: false)); + if (_isCurrentGeneration(pageType, generation)) { + _pageState.updatePageState(pageType, state.copyWith(isLoadingMore: false)); + } return; } @@ -191,7 +237,12 @@ class PageDataLoader { 'โš ๏ธ No next cursor for ${pageType.name} while loading more. ' 'Marking page terminal.', ); - _pageState.updatePageState(pageType, state.copyWith(isLoadingMore: false, hasMore: false)); + if (_isCurrentGeneration(pageType, generation)) { + _pageState.updatePageState( + pageType, + state.copyWith(isLoadingMore: false, hasMore: false), + ); + } return; } @@ -205,6 +256,10 @@ class PageDataLoader { limit: 50, isLiked: isLikedSegment, ); + if (!_isCurrentGeneration(pageType, generation)) { + DebugLogger.debug('๐Ÿ” Discarded stale likes load-more completion'); + return; + } // Re-read after await: concurrent remove/move/segment switch must not // re-append removed rows or clobber the newly selected segment. final latest = _pageState.getStateForPage(pageType); @@ -249,6 +304,11 @@ class PageDataLoader { useCache: pageType != PageType.discover, ); + if (!_isCurrentGeneration(pageType, generation)) { + DebugLogger.debug('๐Ÿ” Discarded stale ${pageType.name} load-more completion'); + return; + } + final pageItems = pageType == PageType.discover ? _pageState.filterOutSessionSwiped(response.items) : response.items; @@ -273,6 +333,7 @@ class PageDataLoader { DebugLogger.success('โœ… Loaded more properties for ${pageType.name} (total: $totalCount)'); } catch (e) { DebugLogger.error('โŒ Failed to load more ${pageType.name} data: $e'); + if (_disposed) return; final state = _pageState.getStateForPage(pageType); _pageState.updatePageState(pageType, state.copyWith(isLoadingMore: false)); } @@ -282,6 +343,7 @@ class PageDataLoader { Future loadMoreData(PageType pageType) => loadMorePageData(pageType); void debounceRefresh(PageType pageType) { + if (_disposed) return; switch (pageType) { case PageType.explore: _exploreDebouncer?.cancel(); @@ -311,7 +373,7 @@ class PageDataLoader { } // Internal: fetch first page of data and update state (cursor reset to null). - Future _fetchAndUpdatePage(PageType pageType) async { + Future _fetchAndUpdatePage(PageType pageType, {required int generation}) async { // Track latency for first property load analytics if (!_firstPropertyLoadedFired) { _firstLoadStartedAt ??= DateTime.now(); @@ -320,6 +382,11 @@ class PageDataLoader { LocationData? loc = state.selectedLocation; loc ??= await _locationController.getInitialLocation(); + if (!_isCurrentGeneration(pageType, generation)) { + DebugLogger.debug('๐Ÿ” Discarded stale ${pageType.name} fetch after location resolve'); + return; + } + DebugLogger.debug( '๐Ÿ“ก [DATA_LOADER] _fetchAndUpdatePage ${pageType.name} ' 'loc=${loc.latitude},${loc.longitude} ' @@ -339,6 +406,11 @@ class PageDataLoader { isLiked: isLikedSegment, ); + if (!_isCurrentGeneration(pageType, generation)) { + DebugLogger.debug('๐Ÿ” Discarded stale likes first-page completion'); + return; + } + // Apply to the segment that was requested. If the user switched // liked/passed mid-flight, only that segment's cache is updated โ€” the // visible list for the new segment is left alone. @@ -358,6 +430,7 @@ class PageDataLoader { selectedLocation: loc, isLoading: false, isRefreshing: false, + isLoadingMore: false, error: null, ), ); @@ -370,10 +443,10 @@ class PageDataLoader { // load was already requested via forceRefresh. _pageState.updatePageState( pageType, - latest.copyWith(isLoading: false, isRefreshing: false, error: null), + latest.copyWith(isLoading: false, isRefreshing: false, isLoadingMore: false, error: null), ); - if (latest.properties.isEmpty && !_pendingLikesReload) { - _pendingLikesReload = true; + if (latest.properties.isEmpty) { + _pendingForceReload.add(PageType.likes); DebugLogger.debug( '๐Ÿ’– Stale likes segment apply left empty list; queuing reload for ' '${_pageState.currentLikesSegment}', @@ -396,6 +469,12 @@ class PageDataLoader { // Discover must not use HTTP cache โ€” a stale page would re-show swiped cards. useCache: pageType != PageType.discover, ); + + if (!_isCurrentGeneration(pageType, generation)) { + DebugLogger.debug('๐Ÿ” Discarded stale ${pageType.name} first-page completion'); + return; + } + DebugLogger.debug( '๐Ÿ“ก [DATA_LOADER] Received ${resp.items.length} properties for ' '${pageType.name} (hasMore=${resp.hasMorePages}, ' @@ -423,6 +502,7 @@ class PageDataLoader { hasMore: resp.hasMorePages, isLoading: false, isRefreshing: false, + isLoadingMore: false, lastFetched: DateTime.now(), error: null, ), diff --git a/test/core/controllers/page_data_loader_test.dart b/test/core/controllers/page_data_loader_test.dart index 9085569c..6fadc1d4 100644 --- a/test/core/controllers/page_data_loader_test.dart +++ b/test/core/controllers/page_data_loader_test.dart @@ -681,6 +681,42 @@ void main() { expect(calls, 2); }); + test('dispose prevents queued force reload from firing', () async { + final gate = Completer(); + var calls = 0; + when( + () => swipesRepo.getSwipeHistoryProperties( + filters: any(named: 'filters'), + latitude: any(named: 'latitude'), + longitude: any(named: 'longitude'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + isLiked: any(named: 'isLiked'), + ), + ).thenAnswer((_) async { + calls++; + return gate.future; + }); + + final first = loader.loadPageData(PageType.likes); + await Future.delayed(Duration.zero); + await loader.loadPageData(PageType.likes, forceRefresh: true); + loader.dispose(); + + gate.complete( + UnifiedPropertyResponse( + items: [testPropertyModel(id: 1)], + nextCursor: null, + hasMore: false, + ), + ); + await first; + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(calls, 1); + }); + test('skips when isLoading is true', () async { when(() => pageState.getStateForPage(any())).thenAnswer( (inv) => cachedState( From 6b994466115a23fb2983887b2e2d4d5e6bc5f8a6 Mon Sep 17 00:00:00 2001 From: Saksham Mittal Date: Fri, 17 Jul 2026 00:15:33 +0530 Subject: [PATCH 6/6] fix: guard load-more error path with request generation A failed load-more must not clear isLoadingMore after a force-refresh has superseded it; match the success-path generation check in catch. --- lib/core/controllers/page_data_loader.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/core/controllers/page_data_loader.dart b/lib/core/controllers/page_data_loader.dart index 8a5ef885..2e1a8ab5 100644 --- a/lib/core/controllers/page_data_loader.dart +++ b/lib/core/controllers/page_data_loader.dart @@ -208,13 +208,16 @@ class PageDataLoader { Future loadMorePageData(PageType pageType) async { if (_disposed) return; + // Hoisted so the catch path can discard stale failures the same way + // success completions do (force-refresh bumps generation mid-flight). + int? generation; try { final state = _pageState.getStateForPage(pageType); if (state.isLoading || state.isLoadingMore || !state.hasMore) return; // Capture generation without bumping so a concurrent force refresh // (which bumps) invalidates this append. - final generation = _requestGeneration[pageType] ?? 0; + generation = _requestGeneration[pageType] ?? 0; _pageState.updatePageState(pageType, state.copyWith(isLoadingMore: true)); final loc = state.selectedLocation; @@ -334,6 +337,12 @@ class PageDataLoader { } catch (e) { DebugLogger.error('โŒ Failed to load more ${pageType.name} data: $e'); if (_disposed) return; + // Do not clear isLoadingMore for a newer load (force-refresh / later + // pagination) if this failure is from a superseded generation. + if (generation == null || !_isCurrentGeneration(pageType, generation)) { + DebugLogger.debug('๐Ÿ” Discarded stale ${pageType.name} load-more error'); + return; + } final state = _pageState.getStateForPage(pageType); _pageState.updatePageState(pageType, state.copyWith(isLoadingMore: false)); }