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..471b5821 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,4 +1,24 @@
-org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError --enable-native-access=ALL-UNNAMED
+# 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; competes with Gradle -Xmx).
+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..8b755825 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -18,8 +18,9 @@ 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
+ // 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
// 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..2e1a8ab5 100644
--- a/lib/core/controllers/page_data_loader.dart
+++ b/lib/core/controllers/page_data_loader.dart
@@ -20,6 +20,20 @@ class PageDataLoader {
final Set _activeLoads = {};
static const Duration _staleLoadingGuardWindow = Duration(seconds: 20);
+ /// 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;
Timer? _discoverDebouncer;
@@ -32,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 {
@@ -60,42 +87,56 @@ class PageDataLoader {
_pageState.updatePageState(pageType, state);
}
- if (state.isLoading || state.isRefreshing || _activeLoads.contains(pageType)) return;
+ if (state.isLoading || state.isRefreshing || _activeLoads.contains(pageType)) {
+ // 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;
+ }
final hasCached = state.properties.isNotEmpty;
final isStale = state.isDataStale;
// 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),
),
);
})
.whenComplete(() {
- _activeLoads.remove(pageType);
- _pageState.notifyPageRefreshing(pageType, false);
+ _finishActiveLoad(pageType);
}),
);
} else {
@@ -104,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(
@@ -114,17 +157,40 @@ class PageDataLoader {
state.copyWith(
isLoading: false,
isRefreshing: false,
+ isLoadingMore: false,
error: ErrorMapper.mapApiError(e, stackTrace),
),
);
} finally {
if (activeLoadRegistered && !launchedBackgroundLoad) {
- _activeLoads.remove(pageType);
- _pageState.notifyPageRefreshing(pageType, false);
+ _finishActiveLoad(pageType);
}
}
}
+ void _finishActiveLoad(PageType pageType) {
+ _activeLoads.remove(pageType);
+ if (_disposed) return;
+
+ _pageState.notifyPageRefreshing(pageType, false);
+ // 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));
+ }
+
+ 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(() {
+ if (_disposed) return;
+ loadPageData(pageType, forceRefresh: true);
+ });
+ }
+
bool _shouldHealStaleLoadingState(PageType pageType, PageStateModel state) {
if (_activeLoads.contains(pageType)) return false;
@@ -141,10 +207,17 @@ 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.
+ generation = _requestGeneration[pageType] ?? 0;
_pageState.updatePageState(pageType, state.copyWith(isLoadingMore: true));
final loc = state.selectedLocation;
@@ -153,7 +226,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;
}
@@ -165,13 +240,17 @@ 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;
}
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,
@@ -180,16 +259,41 @@ class PageDataLoader {
limit: 50,
isLiked: isLikedSegment,
);
- final newProperties = [...state.properties, ...response.items];
+ 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);
+ final stillOnSegment = (_pageState.currentLikesSegment == '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),
@@ -199,13 +303,27 @@ 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];
+ 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;
+ // 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,
@@ -218,6 +336,13 @@ 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;
+ // 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));
}
@@ -227,6 +352,7 @@ class PageDataLoader {
Future loadMoreData(PageType pageType) => loadMorePageData(pageType);
void debounceRefresh(PageType pageType) {
+ if (_disposed) return;
switch (pageType) {
case PageType.explore:
_exploreDebouncer?.cancel();
@@ -256,7 +382,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();
@@ -265,6 +391,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} '
@@ -272,8 +403,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,
@@ -283,23 +415,58 @@ class PageDataLoader {
isLiked: isLikedSegment,
);
- _pageState.updatePageState(
- pageType,
- state.copyWith(
- properties: resp.items,
- selectedLocation: loc,
- nextCursor: resp.nextCursor,
- hasMore: resp.hasMorePages,
- isLoading: false,
- isRefreshing: false,
- lastFetched: DateTime.now(),
- error: null,
- ),
+ 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.
+ _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 = (_pageState.currentLikesSegment == 'liked') == isLikedSegment;
+ if (stillOnRequested) {
+ _pageState.updatePageState(
+ pageType,
+ latest.copyWith(
+ selectedLocation: loc,
+ isLoading: false,
+ isRefreshing: false,
+ isLoadingMore: false,
+ error: null,
+ ),
+ );
+ } 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, isLoadingMore: false, error: null),
+ );
+ if (latest.properties.isEmpty) {
+ _pendingForceReload.add(PageType.likes);
+ DebugLogger.debug(
+ '๐ Stale likes segment apply left empty list; queuing reload for '
+ '${_pageState.currentLikesSegment}',
+ );
+ }
+ }
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,
@@ -308,22 +475,43 @@ 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,
);
+
+ 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}, '
'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.
+ // If undo reinserted a card while the request was in flight, preserve it.
+ final latest = _pageState.getStateForPage(pageType);
+ final items = pageType == PageType.discover
+ ? _pageState.mergeDiscoverRefreshResults(
+ serverItems: resp.items,
+ localItems: latest.properties,
+ epochAtRequestStart: epochAtStart,
+ )
+ : resp.items;
+
_pageState.updatePageState(
pageType,
- state.copyWith(
- properties: resp.items,
+ latest.copyWith(
+ properties: items,
selectedLocation: loc,
nextCursor: resp.nextCursor,
hasMore: resp.hasMorePages,
isLoading: false,
isRefreshing: false,
+ isLoadingMore: false,
lastFetched: DateTime.now(),
error: null,
),
diff --git a/lib/core/controllers/page_state_service.dart b/lib/core/controllers/page_state_service.dart
index b4490987..f4d8b0d3 100644
--- a/lib/core/controllers/page_state_service.dart
+++ b/lib/core/controllers/page_state_service.dart
@@ -130,6 +130,11 @@ 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();
+ _discoverPreserveIds.clear();
try {
_storage.remove(_exploreStateStorageKey);
@@ -537,16 +542,52 @@ class PageStateService extends GetxController {
// Swipe recording & optimistic mutations
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- Future recordSwipe({required int propertyId, required bool isLiked}) async {
- // Maintain likes list optimistically
+ /// 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 = property ?? _findPropertyInAnyList(propertyId);
if (isLiked) {
- final prop = _findPropertyInAnyList(propertyId);
- if (prop != null) addPropertyToLikes(prop);
+ if (prop != null) {
+ // Single path for optimistic like + segment caches (shared with tests).
+ addPropertyToLikes(prop);
+ }
+ if (currentLikesSegment == 'passed') {
+ removePropertyFromLikes(propertyId);
+ }
} else {
- removePropertyFromLikes(propertyId);
+ // Pass: drop from liked, add to passed.
+ if (prop != null) {
+ addPropertyToPassed(prop);
+ } else {
+ // Still drop any pending like for this id.
+ _optimisticLiked.remove(propertyId);
+ _removeFromLikesSegmentCache('liked', propertyId);
+ }
+ if (currentLikesSegment == 'liked') {
+ removePropertyFromLikes(propertyId);
+ }
}
- // 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
@@ -564,19 +605,58 @@ 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) {
+ _discoverPreserveIds.remove(propertyId);
+ _bumpDiscoverMutation();
final state = discoverState.value;
final updatedList = state.properties.where((p) => p.id != propertyId).toList();
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 = {};
+
+ /// 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;
+ 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);
+ _discoverPreserveIds.add(property.id);
+ _bumpDiscoverMutation();
final state = discoverState.value;
final exists = state.properties.any((p) => p.id == property.id);
if (exists) return;
@@ -584,19 +664,75 @@ 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.
+ ///
+ /// 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);
+ 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);
+ }
+ }
+
+ // 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} undo-reinserted Discover card'
+ '${preserve.length == 1 ? '' : 's'} after fetch merge'
+ '${concurrent ? ' (concurrent mutation)' : ''}',
+ );
+ 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
/// 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 +742,174 @@ 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.
+ /// 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,
+ }) {
+ final optimistic = isLikedSegment ? _optimisticLiked : _optimisticPassed;
+ final opposite = isLikedSegment ? _optimisticPassed : _optimisticLiked;
+ final serverIds = {};
+ 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);
+ 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];
+ }
+
+ /// 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;
+ 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..519eb3e8
--- /dev/null
+++ b/lib/core/data/models/popular_city.dart
@@ -0,0 +1,135 @@
+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 remote
+ /// rows that collide with a popular city by exact case-insensitive main text.
+ ///
+ /// 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 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 || popularKeys.contains(key)) continue;
+ merged.add(r);
+ }
+ return merged;
+ }
+
+ /// Shared view-model for location pickers (modal + full-screen search).
+ static PopularSuggestionsList buildSuggestionsList(String query, List remote) {
+ final popularCount = matching(query).length;
+ final suggestions = mergeWithRemote(query, remote);
+ 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 bool showPopularHeader;
+
+ const PopularSuggestionsList({required this.suggestions, 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 94663d36..4f8fe8d4 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 [];
@@ -204,15 +207,24 @@ 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,
};
+ // Soft location bias ranks nearby results higher. Cap radius so distant
+ // 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}';
- queryParams['radius'] = config.placesRadiusMeters;
+ final configured = int.tryParse(config.placesRadiusMeters) ?? 25000;
+ final biasMeters = configured.clamp(1, 200000);
+ queryParams['radius'] = '$biasMeters';
if (config.placesStrictBounds) {
queryParams['strictbounds'] = 'true';
}
@@ -401,6 +413,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 +465,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..59773496 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,15 +316,17 @@ 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 list = PopularCity.buildSuggestionsList(query, remote);
- if (isSearching && suggestions.isEmpty) {
+ if (isSearching && list.isEmpty) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation(AppDesign.primaryYellow),
@@ -326,7 +334,7 @@ class _LocationPickerModalState extends State {
);
}
- if (placesError.isNotEmpty && suggestions.isEmpty && hasQuery) {
+ if (placesError.isNotEmpty && list.isEmpty && hasQuery) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
@@ -346,7 +354,7 @@ class _LocationPickerModalState extends State {
);
}
- if (suggestions.isEmpty && hasQuery) {
+ if (list.isEmpty && hasQuery) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -362,13 +370,33 @@ class _LocationPickerModalState extends State {
);
}
+ if (list.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
return ListView.builder(
- itemCount: suggestions.length,
+ itemCount: list.listItemCount,
itemBuilder: (context, index) {
- final suggestion = suggestions[index];
+ final suggestion = list.suggestionAt(index);
+ if (suggestion == null) {
+ 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 isPopular = PopularCity.isPopularPlaceId(suggestion.placeId);
return _buildLocationTile(
title: suggestion.mainText,
subtitle: suggestion.secondaryText,
+ isPopular: isPopular,
onTap: () => _selectPlaceSuggestion(suggestion),
);
},
@@ -482,17 +510,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..8424e82d 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';
@@ -12,7 +13,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 +39,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 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;
@@ -92,7 +94,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
@@ -221,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();
}
@@ -269,38 +262,45 @@ 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) {
+ void _openDetails() {
+ if (_properties.isEmpty || _gesturesLocked) {
return;
}
+ widget.onSwipeUp(_properties[0]);
+ }
- final dx = isRight ? cardWidth * 0.4 : -cardWidth * 0.4;
+ /// 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 width = MediaQuery.sizeOf(context).width;
+ final startX = width * 0.3;
_dragNotifier.value = _SwipeDragState(
- position: Offset(dx, 0),
- rotation: isRight ? 0.22 : -0.22,
+ 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;
_sparklesAnimationController.forward();
- widget.onSwipeRight(_properties[0]);
+ widget.onSwipeRight(card);
} else {
- widget.onSwipeLeft(_properties[0]);
+ widget.onSwipeLeft(card);
}
setState(() => _isExiting = true);
_swipeAnimationController.forward();
}
- void _openDetails() {
- if (_properties.isEmpty || _gesturesLocked) {
- return;
- }
- widget.onSwipeUp(_properties[0]);
- }
-
@override
Widget build(BuildContext context) {
if (_properties.isEmpty) {
@@ -328,161 +328,154 @@ 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,
- );
+ 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,
},
- 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]),
+ 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));
+ );
},
),
),
- // 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,
+ // Sparkles animation
+ if (_showSparkles && _isSwipingRight)
+ Positioned.fill(
+ child: AnimatedBuilder(
+ animation: _sparklesAnimation,
+ builder: (context, child) {
+ return IgnorePointer(child: _SparklesWidget(animation: _sparklesAnimation));
+ },
+ ),
),
- ),
- ],
+ ],
+ ),
),
);
},
);
}
- /// 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 +483,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..2e329230 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');
@@ -233,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');
@@ -259,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 4879572c..c49ab192 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 {
@@ -101,41 +103,67 @@ 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 list = PopularCity.buildSuggestionsList(query, remote);
+ final isSearching = controller.isLoading.value || locationController.isSearchingPlaces.value;
+
+ // 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());
}
- if (controller.searchError.value.isNotEmpty) {
+ // 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);
}
- final suggestions = locationController.placeSuggestions;
-
- if (suggestions.isEmpty && controller.searchQuery.value.isNotEmpty) {
+ if (list.isEmpty && query.isNotEmpty) {
return _buildEmptyState(context);
}
- // If there are no suggestions yet and no query, show a gentle prompt
- if (suggestions.isEmpty) {
+ if (list.isEmpty) {
return _buildSearchPrompt(context);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
- itemCount: suggestions.length,
+ itemCount: list.listItemCount,
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),
- );
+ final suggestion = list.suggestionAt(index);
+ if (suggestion == null) {
+ 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,
+ ),
+ ),
+ );
+ }
+ 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..6fadc1d4 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';
@@ -59,6 +61,35 @@ 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.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'),
+ nextCursor: any(named: 'nextCursor'),
+ ),
+ ).thenReturn(null);
+ when(() => pageState.currentLikesSegment).thenReturn('liked');
// LocationController stubs.
when(
@@ -590,6 +621,100 @@ 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('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 {
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..054c7066 100644
--- a/test/core/controllers/page_state_service_test.dart
+++ b/test/core/controllers/page_state_service_test.dart
@@ -322,11 +322,214 @@ 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('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(
+ '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
new file mode 100644
index 00000000..22d40060
--- /dev/null
+++ b/test/core/data/models/popular_city_test.dart
@@ -0,0 +1,100 @@
+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 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();
+ expect(loc.name, city.name);
+ 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/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/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'));
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