From 00607d89923bcd0408a16a30ce2949dc5ded9cb1 Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 17:17:08 +0700 Subject: [PATCH 01/13] [Data] Load mock store data from local JSON assets - Update `FakeFirebaseStoreApiHelper` to load store data from JSON files in the `assets/stores_data` directory instead of using hardcoded helper methods. - Introduce `loadStoresFromAssets` using `Gson` to parse store information for multiple brands (Circle K, MiniStop, FamilyMart, etc.). - Update `RepositoryModule` to provide `@ApplicationContext` to `FakeFirebaseStoreApiHelper` to facilitate asset access. - Refactor `insertOrUpdateComment` in the mock helper to use Kotlin's `getOrPut` for cleaner logic. --- .../src/mock/assets/stores_data}/711.json | 0 .../src/mock/assets/stores_data}/bsmart.json | 0 .../mock/assets/stores_data}/circle_k.json | 0 .../mock/assets/stores_data}/familymart.json | 0 .../mock/assets/stores_data}/ministop.json | 0 .../src/mock/assets/stores_data}/shopngo.json | 0 .../store/FakeFirebaseStoreApiHelper.kt | 57 +++++++++++++++---- .../fastfoodfinder/di/RepositoryModule.kt | 4 +- 8 files changed, 49 insertions(+), 12 deletions(-) rename {stores_data => app/src/mock/assets/stores_data}/711.json (100%) rename {stores_data => app/src/mock/assets/stores_data}/bsmart.json (100%) rename {stores_data => app/src/mock/assets/stores_data}/circle_k.json (100%) rename {stores_data => app/src/mock/assets/stores_data}/familymart.json (100%) rename {stores_data => app/src/mock/assets/stores_data}/ministop.json (100%) rename {stores_data => app/src/mock/assets/stores_data}/shopngo.json (100%) diff --git a/stores_data/711.json b/app/src/mock/assets/stores_data/711.json similarity index 100% rename from stores_data/711.json rename to app/src/mock/assets/stores_data/711.json diff --git a/stores_data/bsmart.json b/app/src/mock/assets/stores_data/bsmart.json similarity index 100% rename from stores_data/bsmart.json rename to app/src/mock/assets/stores_data/bsmart.json diff --git a/stores_data/circle_k.json b/app/src/mock/assets/stores_data/circle_k.json similarity index 100% rename from stores_data/circle_k.json rename to app/src/mock/assets/stores_data/circle_k.json diff --git a/stores_data/familymart.json b/app/src/mock/assets/stores_data/familymart.json similarity index 100% rename from stores_data/familymart.json rename to app/src/mock/assets/stores_data/familymart.json diff --git a/stores_data/ministop.json b/app/src/mock/assets/stores_data/ministop.json similarity index 100% rename from stores_data/ministop.json rename to app/src/mock/assets/stores_data/ministop.json diff --git a/stores_data/shopngo.json b/app/src/mock/assets/stores_data/shopngo.json similarity index 100% rename from stores_data/shopngo.json rename to app/src/mock/assets/stores_data/shopngo.json diff --git a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt b/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt index 5d6a4ae1..564a00e9 100644 --- a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt +++ b/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt @@ -1,33 +1,70 @@ package com.iceteaviet.fastfoodfinder.data.remote.store +import android.content.Context +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store -import com.iceteaviet.fastfoodfinder.utils.exception.NotFoundException -import java.util.* +import com.iceteaviet.fastfoodfinder.utils.StoreType +import java.util.TreeMap import kotlin.collections.ArrayList -class FakeFirebaseStoreApiHelper : StoreApiHelper { +class FakeFirebaseStoreApiHelper(private val context: Context) : StoreApiHelper { private var STORE_SERVICE_DATA: MutableList = ArrayList() private var STORE_COMMENT_SERVICE_DATA: MutableMap> = TreeMap() override suspend fun getAllStores(): List { if (STORE_SERVICE_DATA.isEmpty()) { - STORE_SERVICE_DATA = com.iceteaviet.fastfoodfinder.utils.getFakeStoreList().toMutableList() + STORE_SERVICE_DATA = loadStoresFromAssets().toMutableList() } return STORE_SERVICE_DATA } override suspend fun getComments(storeId: String): List { - return STORE_COMMENT_SERVICE_DATA.get(storeId) ?: ArrayList() + return STORE_COMMENT_SERVICE_DATA[storeId] ?: ArrayList() } override fun insertOrUpdateComment(storeId: String, comment: Comment) { - var comments = STORE_COMMENT_SERVICE_DATA.get(storeId) - if (comments == null) { - comments = ArrayList() - } + val comments = STORE_COMMENT_SERVICE_DATA.getOrPut(storeId) { ArrayList() } comments.add(comment) - STORE_COMMENT_SERVICE_DATA.put(storeId, comments) + } + + private fun loadStoresFromAssets(): List { + val gson = Gson() + val stores = ArrayList() + val fileToType = mapOf( + "circle_k.json" to StoreType.TYPE_CIRCLE_K, + "ministop.json" to StoreType.TYPE_MINI_STOP, + "familymart.json" to StoreType.TYPE_FAMILY_MART, + "bsmart.json" to StoreType.TYPE_BSMART, + "shopngo.json" to StoreType.TYPE_SHOP_N_GO, + "711.json" to StoreType.TYPE_7_ELEVEN, + ) + + var id = 1 + for ((fileName, type) in fileToType) { + try { + val json = context.assets.open("stores_data/$fileName") + .bufferedReader().use { it.readText() } + val root = gson.fromJson(json, object : TypeToken>>>() {}) as Map + @Suppress("UNCHECKED_CAST") + val markers = root["markers_add"] as? List> ?: continue + for (marker in markers) { + stores.add(Store( + id++, + marker["title"].orEmpty(), + marker["address"].orEmpty(), + marker["lat"] ?: "0", + marker["lng"] ?: "0", + marker["tel"].orEmpty(), + type + )) + } + } catch (e: Exception) { + throw RuntimeException("Failed to load stores from stores_data/$fileName", e) + } + } + return stores } } diff --git a/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt b/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt index 12f0ace2..78ee0856 100644 --- a/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt +++ b/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt @@ -31,8 +31,8 @@ object RepositoryModule { @Provides @Singleton - fun provideStoreRepository(): StoreRepository { - val remote = FakeFirebaseStoreApiHelper() + fun provideStoreRepository(@ApplicationContext context: Context): StoreRepository { + val remote = FakeFirebaseStoreApiHelper(context) val local = FakeStoreDAO() return AppStoreRepository(remote, local) } From c949ca170e5dd7f01a5cb999f2f92dcece7524bb Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 22:13:27 +0700 Subject: [PATCH 02/13] [Data] Refactor store repository and data synchronization logic - Implement `refreshStores()` in `StoreRepository` and `AppStoreRepository` to handle remote data fetching, filtering, and local persistence. - Move store data validation logic from `DataUtils` to `AppStoreRepository.filterInvalidData`. - Refactor `StoreSyncHelper` to use the new `refreshStoresFromRemote` flow, simplifying remote data updates across `SyncDatabaseWorker`, `SettingViewModel`, and `SplashViewModel`. - Update `StoreListViewModel` to fetch real data from `StoreRepository` instead of using mock data. - Optimize `MainMapViewModel` to prevent redundant store loading when the map is initialized. - Remove unused `findStoresBy` methods from `StoreRepository` and `AppStoreRepository`. - Delete `FakeStoreDAO` and update `RepositoryModule` to provide the real `StoreDao` in mock builds. - Add `AppStoreRepositoryFilterTest` and migrate related tests from `DataUtilsTest`. --- .../data/domain/store/AppStoreRepository.kt | 49 +++++++------ .../data/domain/store/StoreRepository.kt | 6 +- .../service/workers/SyncDatabaseWorker.kt | 9 +-- .../ui/main/map/MainMapViewModel.kt | 6 +- .../ui/settings/SettingViewModel.kt | 17 ++--- .../ui/splash/SplashViewModel.kt | 16 ++-- .../ui/storelist/StoreListViewModel.kt | 17 ++++- .../fastfoodfinder/utils/DataUtils.kt | 19 ----- .../fastfoodfinder/utils/Extensions.kt | 12 --- .../fastfoodfinder/utils/StoreSyncHelper.kt | 11 ++- .../data/local/db/store/FakeStoreDAO.kt | 49 ------------- .../fastfoodfinder/di/RepositoryModule.kt | 8 +- .../store/AppStoreRepositoryFilterTest.kt | 73 +++++++++++++++++++ .../fastfoodfinder/utils/DataUtilsTest.kt | 45 ------------ 14 files changed, 149 insertions(+), 188 deletions(-) delete mode 100644 app/src/mock/java/com/iceteaviet/fastfoodfinder/data/local/db/store/FakeStoreDAO.kt create mode 100644 app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryFilterTest.kt diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt index c42d345f..15a4a2fa 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt @@ -9,6 +9,8 @@ import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store import com.iceteaviet.fastfoodfinder.utils.exception.NotFoundException import com.iceteaviet.fastfoodfinder.utils.getStoreTypeFromQuery +import com.iceteaviet.fastfoodfinder.utils.isValidLat +import com.iceteaviet.fastfoodfinder.utils.isValidLng import com.iceteaviet.fastfoodfinder.utils.standardizeDistrictQuery import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -16,7 +18,7 @@ import kotlinx.coroutines.withContext class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val storeDao: StoreDao) : StoreRepository { @VisibleForTesting - private var cachedStores: List = ArrayList() + internal var cachedStores: List = ArrayList() override suspend fun getAllStores(): List = withContext(Dispatchers.IO) { if (cachedStores.isNotEmpty()) { @@ -30,13 +32,27 @@ class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val } val remoteStores = storeApiHelper.getAllStores() - cachedStores = remoteStores - ArrayList(remoteStores) + val filtered = filterInvalidData(remoteStores) + persistStores(filtered) + ArrayList(filtered) + } + + override suspend fun refreshStores(): List = withContext(Dispatchers.IO) { + val remoteStores = storeApiHelper.getAllStores() + val filtered = filterInvalidData(remoteStores) + persistStores(filtered) + ArrayList(filtered) } override suspend fun setStores(storeList: List) = withContext(Dispatchers.IO) { - cachedStores = storeList - storeDao.insertAll(storeList.map { it.toEntity() }) + persistStores(storeList) + } + + private suspend fun persistStores(stores: List) { + cachedStores = stores + if (stores.isNotEmpty()) { + storeDao.insertAll(stores.map { it.toEntity() }) + } } override suspend fun getStoreInBounds(lat: Double, lng: Double, radius: Double): List = withContext(Dispatchers.IO) { @@ -57,22 +73,6 @@ class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val customQuerySearch.flatMap { storeDao.findStoresByCustomAddress(it).map { e -> e.toDomain() } }.distinctBy { it.id } } - override suspend fun findStoresBy(key: String, value: Int): List = withContext(Dispatchers.IO) { - when (key) { - "type" -> storeDao.findStoresByType(value).map { it.toDomain() } - "id" -> storeDao.findStoreById(value)?.let { listOf(it.toDomain()) } ?: emptyList() - else -> emptyList() - } - } - - override suspend fun findStoresBy(key: String, values: List): List = withContext(Dispatchers.IO) { - when (key) { - "id" -> storeDao.findStoresByIds(values).map { it.toDomain() } - "type" -> values.flatMap { storeDao.findStoresByType(it).map { e -> e.toDomain() } }.distinctBy { it.id } - else -> emptyList() - } - } - override suspend fun findStoresByType(type: Int): List = withContext(Dispatchers.IO) { storeDao.findStoresByType(type).map { it.toDomain() } } @@ -101,4 +101,11 @@ class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val fun clearCache() { cachedStores = ArrayList() } + + @VisibleForTesting + internal fun filterInvalidData(stores: List): List { + return stores.filter { store -> + store.id >= 0 && isValidLat(store.lat) && isValidLng(store.lng) && store.address.isNotBlank() + } + } } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRepository.kt index d12760bb..9078a163 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRepository.kt @@ -7,6 +7,8 @@ interface StoreRepository { suspend fun getAllStores(): List + suspend fun refreshStores(): List + suspend fun setStores(storeList: List) suspend fun getStoreInBounds(lat: Double, lng: Double, radius: Double): List @@ -15,10 +17,6 @@ interface StoreRepository { suspend fun findStoresByCustomAddress(customQuerySearch: List): List - suspend fun findStoresBy(key: String, value: Int): List - - suspend fun findStoresBy(key: String, values: List): List - suspend fun findStoresByType(type: Int): List suspend fun findStoreById(id: Int): Store diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/service/workers/SyncDatabaseWorker.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/service/workers/SyncDatabaseWorker.kt index 7dec9bbd..b600b5e8 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/service/workers/SyncDatabaseWorker.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/service/workers/SyncDatabaseWorker.kt @@ -11,7 +11,6 @@ import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.utils.StoreSyncHelper -import com.iceteaviet.fastfoodfinder.utils.filterInvalidData import com.iceteaviet.fastfoodfinder.utils.ui.NotiManager import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -33,13 +32,11 @@ class SyncDatabaseWorker @AssistedInject constructor( ) return try { - val storeList = StoreSyncHelper.loadStoresFromServer(applicationContext, clientAuth, storeRepository) - val filteredStoreList = filterInvalidData(storeList.toMutableList()) - storeRepository.setStores(filteredStoreList) + val storeList = StoreSyncHelper.refreshStoresFromRemote(applicationContext, clientAuth, storeRepository) - if (filteredStoreList.isNotEmpty()) { + if (storeList.isNotEmpty()) { notiManager.showStoreSyncStatusNotification( - String.format(applicationContext.getString(R.string.update_database_successfull_with_count), filteredStoreList.size), + String.format(applicationContext.getString(R.string.update_database_successfull_with_count), storeList.size), applicationContext.getString(R.string.str_update_app_db) ) Result.success() diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt index a72c0cac..2e3aede8 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt @@ -78,6 +78,7 @@ class MainMapViewModel @Inject constructor( private var isZoomToUser = false private var locationGranted = false + private var isStoresLoaded = false private val cameraPositionFlow = MutableSharedFlow(extraBufferCapacity = 64) private val newVisibleStoreFlow = MutableSharedFlow(extraBufferCapacity = 64) @@ -131,7 +132,9 @@ class MainMapViewModel @Inject constructor( _uiState.value = MainMapEvent.SetupMap - loadAllStoresToMap() + if (!isStoresLoaded) { + loadAllStoresToMap() + } } override fun onCleared() { @@ -329,6 +332,7 @@ class MainMapViewModel @Inject constructor( try { val allStores = storeRepository.getAllStores() storeList = allStores + isStoresLoaded = true if (storeList.isEmpty()) { _uiState.value = MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed) } else { diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModel.kt index a00f7db1..99443e22 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModel.kt @@ -6,8 +6,7 @@ import com.iceteaviet.fastfoodfinder.App import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository -import com.iceteaviet.fastfoodfinder.utils.filterInvalidData -import com.iceteaviet.fastfoodfinder.utils.loadStoresFromServerHelper +import com.iceteaviet.fastfoodfinder.utils.StoreSyncHelper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -56,26 +55,24 @@ class SettingViewModel @Inject constructor( fun onLanguageChanged() { val currentIsViet = _uiState.value.isVietnamese val nextIsViet = !currentIsViet - + val langCode = if (nextIsViet) "vi" else "en" - + _uiState.value = _uiState.value.copy( isVietnamese = nextIsViet, event = SettingEvent.LoadLanguage(langCode) ) - + preferencesRepository.setIfLanguageIsVietnamese(nextIsViet) } fun onLoadStoreFromServer() { _uiState.value = _uiState.value.copy(showLoadingProgressIndicator = true) - + viewModelScope.launch { try { - val storeList = loadStoresFromServerHelper(App.getContext(), clientAuth, storeRepository) - val filteredStoreList = filterInvalidData(storeList.toMutableList()) - storeRepository.setStores(filteredStoreList) - + StoreSyncHelper.refreshStoresFromRemote(App.getContext(), clientAuth, storeRepository) + _uiState.value = _uiState.value.copy( showLoadingProgressIndicator = false, event = SettingEvent.ShowSuccessLoadingToast("") diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModel.kt index 495a5b3e..6b81e6de 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModel.kt @@ -7,10 +7,9 @@ import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository +import com.iceteaviet.fastfoodfinder.utils.StoreSyncHelper import com.iceteaviet.fastfoodfinder.utils.exception.EmptyDataException -import com.iceteaviet.fastfoodfinder.utils.filterInvalidData import com.iceteaviet.fastfoodfinder.utils.isValidUserUid -import com.iceteaviet.fastfoodfinder.utils.loadStoresFromServerHelper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -60,7 +59,7 @@ class SplashViewModel @Inject constructor( fun loadStoresFromServer() { viewModelScope.launch { try { - loadStoresFromServerInternal() + refreshStoresFromRemote() if (clientAuth.isSignedIn() && isValidUserUid(clientAuth.getCurrentUserUid())) { _uiState.value = SplashUiState.NavigateToMain(getSplashRemainingTime()) } else { @@ -80,7 +79,7 @@ class SplashViewModel @Inject constructor( preferencesRepository.setAppLaunchFirstTime(false) viewModelScope.launch { try { - loadStoresFromServerInternal() + refreshStoresFromRemote() _uiState.value = SplashUiState.NavigateToLogin } catch (e: Exception) { e.printStackTrace() @@ -90,13 +89,10 @@ class SplashViewModel @Inject constructor( } } - private suspend fun loadStoresFromServerInternal() { + private suspend fun refreshStoresFromRemote() { try { - val storeList = loadStoresFromServerHelper(App.getContext(), clientAuth, storeRepository) - if (storeList.isNotEmpty()) { - val filteredStoreList = filterInvalidData(storeList.toMutableList()) - storeRepository.setStores(filteredStoreList) - } else { + val storeList = StoreSyncHelper.refreshStoresFromRemote(App.getContext(), clientAuth, storeRepository) + if (storeList.isEmpty()) { _uiState.value = SplashUiState.ShowRetryDialog throw EmptyDataException() } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListViewModel.kt index af7bd820..254cc4ad 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListViewModel.kt @@ -1,12 +1,14 @@ package com.iceteaviet.fastfoodfinder.ui.storelist import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store -import com.iceteaviet.fastfoodfinder.utils.getFakeStoreList import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import javax.inject.Inject data class StoreListUiState( @@ -14,12 +16,21 @@ data class StoreListUiState( ) @HiltViewModel -class StoreListViewModel @Inject constructor() : ViewModel() { +class StoreListViewModel @Inject constructor( + private val storeRepository: StoreRepository +) : ViewModel() { private val _uiState = MutableStateFlow(StoreListUiState()) val uiState: StateFlow = _uiState.asStateFlow() fun start() { - _uiState.value = StoreListUiState(stores = getFakeStoreList()) + viewModelScope.launch { + try { + val stores = storeRepository.getAllStores() + _uiState.value = StoreListUiState(stores = stores) + } catch (e: Exception) { + _uiState.value = StoreListUiState(stores = emptyList()) + } + } } } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt index 554a8231..3be87510 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt @@ -345,25 +345,6 @@ fun getStoreSearchString(storeType: Int): String { } -fun filterInvalidData(stores: MutableList): MutableList { - val result = ArrayList() - - for (i in 0 until stores.size) { - val store = stores.elementAt(i) - if (store.id < 0) { - continue - } - - if (!isValidLat(store.lat) || !isValidLng(store.lng) || store.address.isBlank()) { - continue - } - - result.add(store) - } - - return result -} - fun isValidUserUid(uid: String): Boolean { return !uid.isBlank() && uid != "null" } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/Extensions.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/Extensions.kt index ab5be79c..d11c4cb9 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/Extensions.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/Extensions.kt @@ -1,21 +1,9 @@ package com.iceteaviet.fastfoodfinder.utils -import android.content.Context import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth -import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store import com.iceteaviet.fastfoodfinder.data.remote.user.model.User -suspend fun loadStoresFromServerHelper(context: Context, clientAuth: ClientAuth, storeRepository: StoreRepository): List { - return if (clientAuth.isSignedIn()) { - storeRepository.getAllStores() - } else { - clientAuth.signInWithEmailAndPassword("anonymous@fastfoodfinder.com", "123456") - storeRepository.getAllStores() - } -} - suspend fun getCurrentUserHelper(clientAuth: ClientAuth, userRepository: UserRepository): User? { val uid = clientAuth.getCurrentUserUid() if (isValidUserUid(uid)) { diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt index 6949923f..72536efb 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt @@ -7,13 +7,16 @@ import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store object StoreSyncHelper { - suspend fun loadStoresFromServer(context: Context, clientAuth: ClientAuth, storeRepository: StoreRepository): List { + suspend fun refreshStoresFromRemote(context: Context, clientAuth: ClientAuth, storeRepository: StoreRepository): List { if (clientAuth.isSignedIn()) { - return storeRepository.getAllStores() + return storeRepository.refreshStores() } else { try { - clientAuth.signInWithEmailAndPassword(context.getString(R.string.downloader_bot_email), context.getString(R.string.downloader_bot_pwd)) - return storeRepository.getAllStores() + clientAuth.signInWithEmailAndPassword( + context.getString(R.string.downloader_bot_email), + context.getString(R.string.downloader_bot_pwd) + ) + return storeRepository.refreshStores() } finally { clientAuth.signOut() } diff --git a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/local/db/store/FakeStoreDAO.kt b/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/local/db/store/FakeStoreDAO.kt deleted file mode 100644 index e75d2a62..00000000 --- a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/local/db/store/FakeStoreDAO.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.iceteaviet.fastfoodfinder.data.local.db.store - -import com.iceteaviet.fastfoodfinder.data.local.db.store.model.StoreEntity -import com.iceteaviet.fastfoodfinder.data.local.db.store.model.toDomain -import com.iceteaviet.fastfoodfinder.data.local.db.store.model.toEntity -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store -import com.iceteaviet.fastfoodfinder.utils.getStoreTypeFromQuery -import com.iceteaviet.fastfoodfinder.utils.standardizeDistrictQuery -import java.util.* -import kotlin.collections.ArrayList - -class FakeStoreDAO : StoreDao { - - private var STORE_SERVICE_DATA: MutableList = ArrayList() - - override suspend fun getAllStores(): List { - return STORE_SERVICE_DATA - } - - override suspend fun getStoreInBounds(minLat: Double, maxLat: Double, minLng: Double, maxLng: Double): List { - return STORE_SERVICE_DATA.filter { it.latitude in minLat..maxLat && it.longitude in minLng..maxLng } - } - - override suspend fun findStoresByCustomAddress(query: String): List { - return STORE_SERVICE_DATA.filter { it.title.contains(query, true) || it.address.contains(query, true) } - } - - override suspend fun findStoresByType(type: Int): List { - return STORE_SERVICE_DATA.filter { it.type == type } - } - - override suspend fun findStoreById(id: Int): StoreEntity? { - return STORE_SERVICE_DATA.find { it.id == id } - } - - override suspend fun findStoresByIds(ids: List): List { - return STORE_SERVICE_DATA.filter { it.id in ids } - } - - override suspend fun insertAll(stores: List) { - if (stores.isNotEmpty()) { - STORE_SERVICE_DATA = ArrayList(stores) - } - } - - override suspend fun deleteAll() { - STORE_SERVICE_DATA.clear() - } -} diff --git a/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt b/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt index 78ee0856..b47cc597 100644 --- a/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt +++ b/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt @@ -10,8 +10,9 @@ import com.iceteaviet.fastfoodfinder.data.domain.store.AppStoreRepository import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.domain.user.AppUserRepository import com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository -import com.iceteaviet.fastfoodfinder.data.local.db.store.FakeStoreDAO +import com.iceteaviet.fastfoodfinder.data.local.db.store.StoreDao import com.iceteaviet.fastfoodfinder.data.local.db.user.FakeUserDAO +import com.iceteaviet.fastfoodfinder.data.local.db.user.UserDao import android.content.Context import com.iceteaviet.fastfoodfinder.data.local.prefs.AppPreferencesHelper import com.iceteaviet.fastfoodfinder.data.local.prefs.AppPreferencesWrapper @@ -31,10 +32,9 @@ object RepositoryModule { @Provides @Singleton - fun provideStoreRepository(@ApplicationContext context: Context): StoreRepository { + fun provideStoreRepository(@ApplicationContext context: Context, storeDao: StoreDao): StoreRepository { val remote = FakeFirebaseStoreApiHelper(context) - val local = FakeStoreDAO() - return AppStoreRepository(remote, local) + return AppStoreRepository(remote, storeDao) } @Provides diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryFilterTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryFilterTest.kt new file mode 100644 index 00000000..2838fde0 --- /dev/null +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryFilterTest.kt @@ -0,0 +1,73 @@ +package com.iceteaviet.fastfoodfinder.data.domain.store + +import com.google.common.truth.Truth.assertThat +import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.utils.StoreType +import org.junit.Test + +class AppStoreRepositoryFilterTest { + + private val repository = AppStoreRepository( + storeApiHelper = object : com.iceteaviet.fastfoodfinder.data.remote.store.StoreApiHelper { + override suspend fun getAllStores(): List = emptyList() + override suspend fun getComments(storeId: String): List = emptyList() + override fun insertOrUpdateComment(storeId: String, comment: com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment) {} + }, + storeDao = object : com.iceteaviet.fastfoodfinder.data.local.db.store.StoreDao { + override suspend fun getAllStores(): List = emptyList() + override suspend fun getStoreInBounds(minLat: Double, maxLat: Double, minLng: Double, maxLng: Double): List = emptyList() + override suspend fun findStoresByCustomAddress(query: String): List = emptyList() + override suspend fun findStoresByType(type: Int): List = emptyList() + override suspend fun findStoreById(id: Int): com.iceteaviet.fastfoodfinder.data.local.db.store.model.StoreEntity? = null + override suspend fun findStoresByIds(ids: List): List = emptyList() + override suspend fun insertAll(stores: List) {} + override suspend fun deleteAll() {} + } + ) + + @Test + fun filterInvalidDataTest_emptyData() { + assertThat(repository.filterInvalidData(emptyList())).isEmpty() + } + + @Test + fun filterInvalidDataTest_invalidData() { + val stores = listOf( + Store(STORE_INVALID_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_LNG, STORE_TEL, STORE_TYPE), + Store(STORE_ID, STORE_TITLE, STORE_INVALID_ADDRESS, STORE_LAT, STORE_LNG, STORE_TEL, STORE_TYPE), + Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_INVALID_LAT, STORE_LNG, STORE_TEL, STORE_TYPE), + Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_INVALID_LNG, STORE_TEL, STORE_TYPE), + ) + + assertThat(repository.filterInvalidData(stores)).isEmpty() + } + + @Test + fun filterInvalidDataTest() { + val stores = listOf( + Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_LNG, STORE_TEL, STORE_TYPE), + Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, "0.0", STORE_LNG, STORE_TEL, STORE_TYPE), + Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, "0.0", STORE_TEL, STORE_TYPE), + Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_LNG, STORE_INVALID_TEL, STORE_TYPE), + ) + + assertThat(repository.filterInvalidData(stores)).hasSize(4) + } + + companion object { + private const val STORE_ID = 123 + private const val STORE_TITLE = "store_title" + private const val STORE_ADDRESS = "store_address" + private const val STORE_LAT = "10.773996" + private const val STORE_LNG = "106.6898035" + private const val STORE_TEL = "012345678965" + + private const val STORE_TYPE = StoreType.TYPE_CIRCLE_K + + private const val STORE_INVALID_ID = -1 + private const val STORE_INVALID_TEL = "" + private const val STORE_INVALID_ADDRESS = "" + private const val STORE_INVALID_LAT = "-1" + private const val STORE_INVALID_LNG = "-1" + } +} diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt index ffb3e215..27e8d63a 100644 --- a/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt @@ -97,36 +97,6 @@ class DataUtilsTest { assertThat(getStoreSearchString(-1)).isEqualTo("") } - @Test - fun filterInvalidDataTest_emptyData() { - val stores = ArrayList() - - assertThat(filterInvalidData(stores)).isEmpty() - } - - @Test - fun filterInvalidDataTest_invalidData() { - val stores = ArrayList() - stores.add(Store(STORE_INVALID_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_LNG, STORE_TEL, STORE_TYPE)) - stores.add(Store(STORE_ID, STORE_TITLE, STORE_INVALID_ADDRESS, STORE_LAT, STORE_LNG, STORE_TEL, STORE_TYPE)) - stores.add(Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_INVALID_LAT, STORE_LNG, STORE_TEL, STORE_TYPE)) - stores.add(Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_INVALID_LNG, STORE_TEL, STORE_TYPE)) - - assertThat(filterInvalidData(stores)).isEmpty() - } - - @Test - fun filterInvalidDataTest() { - val stores = ArrayList() - stores.add(Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_LNG, STORE_TEL, STORE_TYPE)) - stores.add(Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, "0.0", STORE_LNG, STORE_TEL, STORE_TYPE)) - stores.add(Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, "0.0", STORE_TEL, STORE_TYPE)) - stores.add(Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_LNG, STORE_INVALID_TEL, STORE_TYPE)) // This is valid store - - assertThat(filterInvalidData(stores)).hasSize(4) - } - - @Test fun getStoreNameByKeyTest() { assertThat(getStoreNameByKey(KEY_CIRCLE_K)).isEqualTo("Cirle K") @@ -175,21 +145,6 @@ class DataUtilsTest { } companion object { - private const val STORE_ID = 123 - private const val STORE_TITLE = "store_title" - private const val STORE_ADDRESS = "store_address" - private const val STORE_LAT = "10.773996" - private const val STORE_LNG = "106.6898035" - private const val STORE_TEL = "012345678965" - - private const val STORE_TYPE = StoreType.TYPE_CIRCLE_K - - private const val STORE_INVALID_ID = -1 - private const val STORE_INVALID_TEL = "" - private const val STORE_INVALID_ADDRESS = "" - private const val STORE_INVALID_LAT = "-1" - private const val STORE_INVALID_LNG = "-1" - const val KEY_CIRCLE_K = "circle_k" const val KEY_MINI_STOP = "mini_stop" const val KEY_FAMILY_MART = "family_mark" From 707aef618c32805918e157d8e04cc3c65d23e114 Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 22:39:18 +0700 Subject: [PATCH 03/13] [Data] Migrate `Store` model to domain layer and update coordinate types - Move `Store` model from `data.remote.store.model` to `domain.model`. - Refactor `Store` to use `Double` for `lat` and `lng` instead of `String`. - Introduce `StoreDto` in the remote data layer to handle Firebase data mapping and conversion to the domain model. - Add `StoreExt.kt` with a `toLatLng()` extension function to replace the internal `getPosition()` method. - Update `StoreEntity` and database mappers to reflect the change from `String` to `Double` for coordinates. - Update all UI components (Map, Search, Favorites, Detail), ViewModels, and Repositories to use the new domain model and coordinate types. - Update mock data and unit tests in `DataUtils.kt` and `IntentUtilsTest.kt` to use numeric coordinate values. - Simplify coordinate validation logic in `AppStoreRepository`. --- .../routing/AppMapsRoutingRepository.kt | 2 +- .../domain/routing/MapsRoutingRepository.kt | 2 +- .../data/domain/store/AppStoreRepository.kt | 6 +- .../data/domain/store/StoreRepository.kt | 2 +- .../data/local/db/store/model/StoreEntity.kt | 8 +- .../routing/GoogleMapsRoutingApiHelper.kt | 2 +- .../remote/routing/MapsRoutingApiHelper.kt | 2 +- .../remote/store/FirebaseStoreApiHelper.kt | 14 +-- .../data/remote/store/StoreApiHelper.kt | 2 +- .../data/remote/store/model/Store.kt | 113 ------------------ .../data/remote/store/model/StoreDto.kt | 59 +++++++++ .../data/remote/user/model/UserStoreEvent.kt | 2 +- .../data/remote/user/model/UserStoreList.kt | 2 +- .../fastfoodfinder/domain/model/Store.kt | 42 +++++++ .../fastfoodfinder/domain/model/StoreExt.kt | 5 + .../fastfoodfinder/ui/main/MainViewModel.kt | 2 +- .../main/favourite/FavouriteStoreAdapter.kt | 2 +- .../ui/main/favourite/MainFavViewModel.kt | 2 +- .../main/favourite/MainFavouriteFragment.kt | 2 +- .../ui/main/map/MainMapFragment.kt | 7 +- .../ui/main/map/MainMapViewModel.kt | 15 +-- .../ui/main/map/NearByStoreAdapter.kt | 2 +- .../ui/main/map/model/NearByStore.kt | 2 +- .../ui/main/map/storeinfo/StoreInfoDialog.kt | 2 +- .../main/map/storeinfo/StoreInfoViewModel.kt | 2 +- .../ui/main/search/BaseSearchAdapter.kt | 2 +- .../ui/main/search/SearchEventBus.kt | 2 +- .../ui/main/search/SearchFragment.kt | 2 +- .../ui/main/search/SearchViewModel.kt | 2 +- .../ui/main/search/model/SearchStoreItem.kt | 2 +- .../ui/routing/MapRoutingActivity.kt | 5 +- .../ui/routing/MapRoutingViewModel.kt | 7 +- .../ui/store/StoreDetailAdapter.kt | 2 +- .../ui/store/StoreDetailViewModel.kt | 5 +- .../ui/storelist/ListDetailViewModel.kt | 2 +- .../ui/storelist/StoreListAdapter.kt | 2 +- .../ui/storelist/StoreListViewModel.kt | 2 +- .../fastfoodfinder/utils/DataUtils.kt | 50 ++++---- .../fastfoodfinder/utils/StoreSyncHelper.kt | 2 +- .../routing/FakeGoogleMapsRoutingApiHelper.kt | 2 +- .../store/FakeFirebaseStoreApiHelper.kt | 16 +-- .../store/AppStoreRepositoryFilterTest.kt | 14 +-- .../fastfoodfinder/utils/DataUtilsTest.kt | 2 +- .../fastfoodfinder/utils/IntentUtilsTest.kt | 6 +- 44 files changed, 212 insertions(+), 216 deletions(-) delete mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/Store.kt create mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/StoreDto.kt create mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/domain/model/Store.kt create mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/domain/model/StoreExt.kt diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/routing/AppMapsRoutingRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/routing/AppMapsRoutingRepository.kt index 02f74eab..b3098a9c 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/routing/AppMapsRoutingRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/routing/AppMapsRoutingRepository.kt @@ -2,7 +2,7 @@ package com.iceteaviet.fastfoodfinder.data.domain.routing import com.iceteaviet.fastfoodfinder.data.remote.routing.MapsRoutingApiHelper import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/routing/MapsRoutingRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/routing/MapsRoutingRepository.kt index 6fbf200d..1ab97fd2 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/routing/MapsRoutingRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/routing/MapsRoutingRepository.kt @@ -1,7 +1,7 @@ package com.iceteaviet.fastfoodfinder.data.domain.routing import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store interface MapsRoutingRepository { suspend fun getMapsDirection(queries: Map, store: Store): MapsDirection diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt index 15a4a2fa..ce7391df 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt @@ -6,11 +6,9 @@ import com.iceteaviet.fastfoodfinder.data.local.db.store.model.toEntity import com.iceteaviet.fastfoodfinder.data.local.db.store.model.toDomain import com.iceteaviet.fastfoodfinder.data.remote.store.StoreApiHelper import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.utils.exception.NotFoundException import com.iceteaviet.fastfoodfinder.utils.getStoreTypeFromQuery -import com.iceteaviet.fastfoodfinder.utils.isValidLat -import com.iceteaviet.fastfoodfinder.utils.isValidLng import com.iceteaviet.fastfoodfinder.utils.standardizeDistrictQuery import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -105,7 +103,7 @@ class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val @VisibleForTesting internal fun filterInvalidData(stores: List): List { return stores.filter { store -> - store.id >= 0 && isValidLat(store.lat) && isValidLng(store.lng) && store.address.isNotBlank() + store.id >= 0 && store.lat in 0.0..90.0 && store.lng in 0.0..180.0 && store.address.isNotBlank() } } } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRepository.kt index 9078a163..ee661f7f 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRepository.kt @@ -1,7 +1,7 @@ package com.iceteaviet.fastfoodfinder.data.domain.store import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store interface StoreRepository { diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/db/store/model/StoreEntity.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/db/store/model/StoreEntity.kt index 6ba93965..f0669b1f 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/db/store/model/StoreEntity.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/db/store/model/StoreEntity.kt @@ -2,7 +2,7 @@ package com.iceteaviet.fastfoodfinder.data.local.db.store.model import androidx.room.Entity import androidx.room.PrimaryKey -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store @Entity(tableName = "stores") data class StoreEntity( @@ -16,7 +16,7 @@ data class StoreEntity( ) fun StoreEntity.toDomain(): Store { - return Store(id, title, address, latitude.toString(), longitude.toString(), telephone, type) + return Store(id, title, address, latitude, longitude, telephone, type) } fun Store.toEntity(): StoreEntity { @@ -25,8 +25,8 @@ fun Store.toEntity(): StoreEntity { type = type, title = title, address = address, - latitude = lat.toDoubleOrNull() ?: 0.0, - longitude = lng.toDoubleOrNull() ?: 0.0, + latitude = lat, + longitude = lng, telephone = tel, ) } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/routing/GoogleMapsRoutingApiHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/routing/GoogleMapsRoutingApiHelper.kt index 297e99a9..b4fdabc8 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/routing/GoogleMapsRoutingApiHelper.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/routing/GoogleMapsRoutingApiHelper.kt @@ -2,7 +2,7 @@ package com.iceteaviet.fastfoodfinder.data.remote.routing import com.iceteaviet.fastfoodfinder.data.remote.ApiEndPoint import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.utils.exception.NotFoundException import com.iceteaviet.fastfoodfinder.utils.get import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/routing/MapsRoutingApiHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/routing/MapsRoutingApiHelper.kt index 3c881fa0..e88eed42 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/routing/MapsRoutingApiHelper.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/routing/MapsRoutingApiHelper.kt @@ -1,7 +1,7 @@ package com.iceteaviet.fastfoodfinder.data.remote.routing import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store interface MapsRoutingApiHelper { suspend fun getMapsDirection(queries: Map, store: Store): MapsDirection diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/FirebaseStoreApiHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/FirebaseStoreApiHelper.kt index 5d9a5234..1b6f4cec 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/FirebaseStoreApiHelper.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/FirebaseStoreApiHelper.kt @@ -5,7 +5,8 @@ import com.google.firebase.database.DatabaseError import com.google.firebase.database.DatabaseReference import com.google.firebase.database.ValueEventListener import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.data.remote.store.model.StoreDto +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.utils.getStoreType import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -41,18 +42,17 @@ class FirebaseStoreApiHelper(private val databaseRef: DatabaseReference) : Store databaseRef.child(CHILD_COMMENT_LIST).child(storeId).push().setValue(comment) } - private fun parseStoresDataFromFirebase(dataSnapshot: DataSnapshot): MutableList { + private fun parseStoresDataFromFirebase(dataSnapshot: DataSnapshot): List { val storeList = ArrayList() for (child in dataSnapshot.children) { for (storeLocation in child.child(CHILD_MARKERS_ADD).children) { - val store = storeLocation.getValue(Store::class.java) - if (store != null) { - store.type = getStoreType(child.key) - storeList.add(store) + val dto = storeLocation.getValue(StoreDto::class.java) + if (dto != null) { + dto.type = getStoreType(child.key) + storeList.add(dto.toDomain()) } } } - return storeList } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/StoreApiHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/StoreApiHelper.kt index 096b758f..169fd5a1 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/StoreApiHelper.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/StoreApiHelper.kt @@ -1,7 +1,7 @@ package com.iceteaviet.fastfoodfinder.data.remote.store import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store interface StoreApiHelper { suspend fun getAllStores(): List diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/Store.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/Store.kt deleted file mode 100644 index e2ca2369..00000000 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/Store.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.iceteaviet.fastfoodfinder.data.remote.store.model - -import android.os.Parcel -import android.os.Parcelable -import com.google.android.gms.maps.model.LatLng -import com.google.firebase.database.Exclude -import com.google.firebase.database.PropertyName - -class Store : Parcelable { - @Exclude - @get:Exclude - @set:Exclude - var type: Int = 0 - - @PropertyName("id") - var id: Int = 0 - - @PropertyName("title") - @set:PropertyName("title") - var title: String = "" - - @PropertyName("address") - var address: String = "" - - @PropertyName("lat") - @set:PropertyName("lat") - var lat: String = "" - - @PropertyName("lng") - @set:PropertyName("lng") - var lng: String = "" - - @PropertyName("tel") - @set:PropertyName("tel") - var tel: String = "" - - @Exclude - private lateinit var position: LatLng - - constructor() - - constructor(id: Int, title: String, address: String, lat: String, lng: String, tel: String, type: Int) { - try { - this.id = id - this.title = title - this.address = address - this.lat = lat - this.lng = lng - this.tel = tel - this.type = type - - position = LatLng(lat.toDouble(), lng.toDouble()) - } catch (e: NumberFormatException) { - e.printStackTrace() - position = LatLng(0.0, 0.0) - } - } - - protected constructor(`in`: Parcel) : this(`in`.readInt(), `in`.readString() - ?: "", `in`.readString() ?: "", `in`.readString() ?: "0", `in`.readString() - ?: "0", `in`.readString() ?: "", `in`.readInt()) - - - @Exclude - fun getPosition(): LatLng { - if (!::position.isInitialized) - position = LatLng(lat.toDouble(), lng.toDouble()) - return position - } - - override fun equals(other: Any?): Boolean { - return if (other is Store) { - this.id == other.id && this.title == other.title - } else false - - } - - override fun describeContents(): Int { - return 0 - } - - override fun writeToParcel(parcel: Parcel, i: Int) { - parcel.writeInt(id) - parcel.writeString(title) - parcel.writeString(address) - parcel.writeString(lat) - parcel.writeString(lng) - parcel.writeString(tel) - parcel.writeInt(type) - } - - override fun hashCode(): Int { - var result = type - result = 31 * result + id - result = 31 * result + title.hashCode() - result = 31 * result + address.hashCode() - result = 31 * result + lat.hashCode() - result = 31 * result + lng.hashCode() - result = 31 * result + tel.hashCode() - result = 31 * result + position.hashCode() - return result - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): Store { - return Store(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/StoreDto.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/StoreDto.kt new file mode 100644 index 00000000..829eb330 --- /dev/null +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/StoreDto.kt @@ -0,0 +1,59 @@ +package com.iceteaviet.fastfoodfinder.data.remote.store.model + +import com.google.firebase.database.Exclude +import com.google.firebase.database.PropertyName +import com.iceteaviet.fastfoodfinder.domain.model.Store + +class StoreDto { + @Exclude + @get:Exclude + @set:Exclude + var type: Int = 0 + + @PropertyName("id") + var id: Int = 0 + + @PropertyName("title") + @set:PropertyName("title") + var title: String = "" + + @PropertyName("address") + @set:PropertyName("address") + var address: String = "" + + @PropertyName("lat") + @set:PropertyName("lat") + var lat: String = "" + + @PropertyName("lng") + @set:PropertyName("lng") + var lng: String = "" + + @PropertyName("tel") + @set:PropertyName("tel") + var tel: String = "" + + constructor() + + constructor(id: Int, title: String, address: String, lat: String, lng: String, tel: String, type: Int) { + this.id = id + this.title = title + this.address = address + this.lat = lat + this.lng = lng + this.tel = tel + this.type = type + } + + fun toDomain(): Store { + return Store( + id = id, + title = title, + address = address, + lat = lat.toDoubleOrNull() ?: 0.0, + lng = lng.toDoubleOrNull() ?: 0.0, + tel = tel, + type = type, + ) + } +} diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/user/model/UserStoreEvent.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/user/model/UserStoreEvent.kt index e7c4530c..82c2731d 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/user/model/UserStoreEvent.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/user/model/UserStoreEvent.kt @@ -1,6 +1,6 @@ package com.iceteaviet.fastfoodfinder.data.remote.user.model -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store /** * Created by tom on 7/17/18. diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/user/model/UserStoreList.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/user/model/UserStoreList.kt index 17e9f622..1a378d24 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/user/model/UserStoreList.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/user/model/UserStoreList.kt @@ -3,7 +3,7 @@ package com.iceteaviet.fastfoodfinder.data.remote.user.model import android.os.Parcel import android.os.Parcelable import com.google.firebase.database.PropertyName -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store class UserStoreList : Parcelable { var id: Int = -1 diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/domain/model/Store.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/domain/model/Store.kt new file mode 100644 index 00000000..9a95a2f9 --- /dev/null +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/domain/model/Store.kt @@ -0,0 +1,42 @@ +package com.iceteaviet.fastfoodfinder.domain.model + +import android.os.Parcel +import android.os.Parcelable + +data class Store( + val id: Int = 0, + val title: String = "", + val address: String = "", + val lat: Double = 0.0, + val lng: Double = 0.0, + val tel: String = "", + val type: Int = 0, +) : Parcelable { + + constructor(parcel: Parcel) : this( + parcel.readInt(), + parcel.readString() ?: "", + parcel.readString() ?: "", + parcel.readDouble(), + parcel.readDouble(), + parcel.readString() ?: "", + parcel.readInt(), + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeInt(id) + parcel.writeString(title) + parcel.writeString(address) + parcel.writeDouble(lat) + parcel.writeDouble(lng) + parcel.writeString(tel) + parcel.writeInt(type) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): Store = Store(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/domain/model/StoreExt.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/domain/model/StoreExt.kt new file mode 100644 index 00000000..2b988a8e --- /dev/null +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/domain/model/StoreExt.kt @@ -0,0 +1,5 @@ +package com.iceteaviet.fastfoodfinder.domain.model + +import com.google.android.gms.maps.model.LatLng + +fun Store.toLatLng(): LatLng = LatLng(lat, lng) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainViewModel.kt index 2ac77d59..621ea448 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository import com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.ui.main.search.SearchEventBus import com.iceteaviet.fastfoodfinder.ui.main.search.SearchEventResult import com.iceteaviet.fastfoodfinder.utils.Constant diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/FavouriteStoreAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/FavouriteStoreAdapter.kt index 523fb680..7ae63442 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/FavouriteStoreAdapter.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/FavouriteStoreAdapter.kt @@ -12,7 +12,7 @@ import androidx.core.view.MotionEventCompat import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.databinding.ItemFavouriteLocationBinding import com.iceteaviet.fastfoodfinder.ui.custom.itemtouchhelper.ItemTouchHelperAdapter import com.iceteaviet.fastfoodfinder.ui.custom.itemtouchhelper.ItemTouchHelperViewHolder diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/MainFavViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/MainFavViewModel.kt index 866d5c86..d65a10dc 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/MainFavViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/MainFavViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.data.remote.user.model.UserStoreEvent import com.iceteaviet.fastfoodfinder.utils.exception.EmptyParamsException import com.iceteaviet.fastfoodfinder.utils.isValidUserUid diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/MainFavouriteFragment.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/MainFavouriteFragment.kt index 30a12012..7f8d1218 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/MainFavouriteFragment.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/favourite/MainFavouriteFragment.kt @@ -18,7 +18,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.floatingactionbutton.FloatingActionButton import dagger.hilt.android.AndroidEntryPoint import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.databinding.FragmentMainFavouritedBinding import com.iceteaviet.fastfoodfinder.ui.custom.itemtouchhelper.OnStartDragListener import com.iceteaviet.fastfoodfinder.ui.custom.itemtouchhelper.SimpleItemTouchHelperCallback diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapFragment.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapFragment.kt index e79414a1..94d0d10e 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapFragment.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapFragment.kt @@ -29,7 +29,8 @@ import dagger.hilt.android.AndroidEntryPoint import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.core.location.GoogleLocationManager import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.toLatLng import com.iceteaviet.fastfoodfinder.databinding.FragmentMainMapBinding import com.iceteaviet.fastfoodfinder.ui.main.map.model.NearByStore import com.iceteaviet.fastfoodfinder.ui.main.map.storeinfo.StoreInfoDialog @@ -218,10 +219,10 @@ class MainMapFragment : Fragment() { val bounds = googleMap?.projection?.visibleRegion?.latLngBounds for (i in storeList.indices) { val store = storeList[i] - if (bounds != null && !bounds.contains(store.getPosition())) continue + if (bounds != null && !bounds.contains(store.toLatLng())) continue val marker = googleMap!!.addMarker( - MarkerOptions().position(store.getPosition()) + MarkerOptions().position(store.toLatLng()) .title(store.title) .snippet(store.address) .icon(getStoreIcon(resources, store.type, -1, -1)) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt index 2e3aede8..eca67802 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt @@ -13,7 +13,8 @@ import com.iceteaviet.fastfoodfinder.data.domain.routing.MapsRoutingRepository import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.remote.routing.GoogleMapsRoutingApiHelper import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.toLatLng import com.iceteaviet.fastfoodfinder.ui.main.search.SearchEventBus import com.iceteaviet.fastfoodfinder.ui.main.search.SearchEventResult import com.iceteaviet.fastfoodfinder.ui.main.map.model.MapCameraPosition @@ -186,7 +187,7 @@ class MainMapViewModel @Inject constructor( } fun onNavigationButtonClick(store: Store) { - val storeLocation = store.getPosition() + val storeLocation = store.toLatLng() val queries = HashMap() if (!isValidLocation(storeLocation)) { @@ -271,7 +272,7 @@ class MainMapViewModel @Inject constructor( val stores = ArrayList() for (i in storeList.indices) { val store = storeList[i] - if (bounds.contains(store.getPosition())) { + if (bounds.contains(store.toLatLng())) { stores.add(store) } } @@ -281,7 +282,7 @@ class MainMapViewModel @Inject constructor( private fun generateNearByStoresWithDistance(currPos: LatLng, stores: List): List { val res = ArrayList() for (store in stores) { - res.add(NearByStore(store, distanceBetween(currPos, store.getPosition()))) + res.add(NearByStore(store, distanceBetween(currPos, store.toLatLng()))) } return res } @@ -295,7 +296,7 @@ class MainMapViewModel @Inject constructor( } else { storeList = foundStores _uiState.value = MainMapEvent.AddMarkersToMap(storeList) - _uiState.value = MainMapEvent.AnimateMapCamera(storeList[0].getPosition(), false) + _uiState.value = MainMapEvent.AnimateMapCamera(storeList[0].toLatLng(), false) } } catch (e: Exception) { _uiState.value = MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed) @@ -312,7 +313,7 @@ class MainMapViewModel @Inject constructor( } else { storeList = foundStores _uiState.value = MainMapEvent.AddMarkersToMap(storeList) - _uiState.value = MainMapEvent.AnimateMapCamera(storeList[0].getPosition(), false) + _uiState.value = MainMapEvent.AnimateMapCamera(storeList[0].toLatLng(), false) } } catch (e: Exception) { _uiState.value = MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed) @@ -347,7 +348,7 @@ class MainMapViewModel @Inject constructor( private fun handleSearchStoreClickAction(store: Store) { storeList = arrayListOf(store) _uiState.value = MainMapEvent.AddMarkersToMap(storeList) - _uiState.value = MainMapEvent.AnimateMapCamera(store.getPosition(), false) + _uiState.value = MainMapEvent.AnimateMapCamera(store.toLatLng(), false) _uiState.value = MainMapEvent.ClearNearByStores _uiState.value = MainMapEvent.ShowDialogStoreInfo(store) } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/NearByStoreAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/NearByStoreAdapter.kt index 9d9dc2ff..e61d48c2 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/NearByStoreAdapter.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/NearByStoreAdapter.kt @@ -10,7 +10,7 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.databinding.ItemStoreBinding import com.iceteaviet.fastfoodfinder.ui.main.map.model.NearByStore import com.iceteaviet.fastfoodfinder.utils.formatDistance diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/model/NearByStore.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/model/NearByStore.kt index a9a97b40..ccd91499 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/model/NearByStore.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/model/NearByStore.kt @@ -1,6 +1,6 @@ package com.iceteaviet.fastfoodfinder.ui.main.map.model -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store /** * Created by tom on 2019-04-29. diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/storeinfo/StoreInfoDialog.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/storeinfo/StoreInfoDialog.kt index 3fb45967..7df1b74a 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/storeinfo/StoreInfoDialog.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/storeinfo/StoreInfoDialog.kt @@ -20,7 +20,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.databinding.FragmentStoreInfoBinding import com.iceteaviet.fastfoodfinder.ui.store.StoreDetailActivity.Companion.KEY_STORE import com.iceteaviet.fastfoodfinder.ui.store.StoreDetailAdapter diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/storeinfo/StoreInfoViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/storeinfo/StoreInfoViewModel.kt index e4872caa..c1bf1039 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/storeinfo/StoreInfoViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/storeinfo/StoreInfoViewModel.kt @@ -2,7 +2,7 @@ package com.iceteaviet.fastfoodfinder.ui.main.map.storeinfo import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.ui.store.StoreDetailActivity.Companion.KEY_STORE import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/BaseSearchAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/BaseSearchAdapter.kt index 2603a88b..83006b83 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/BaseSearchAdapter.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/BaseSearchAdapter.kt @@ -2,7 +2,7 @@ package com.iceteaviet.fastfoodfinder.ui.main.search import android.view.View import androidx.recyclerview.widget.RecyclerView -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.ui.main.search.model.SearchStoreItem /** diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchEventBus.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchEventBus.kt index a0f09bb0..c62bf9a5 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchEventBus.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchEventBus.kt @@ -1,6 +1,6 @@ package com.iceteaviet.fastfoodfinder.ui.main.search -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchFragment.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchFragment.kt index 26f6485c..20ea9642 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchFragment.kt @@ -18,7 +18,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.databinding.FragmentSearchBinding import com.iceteaviet.fastfoodfinder.utils.StoreType import com.iceteaviet.fastfoodfinder.utils.openStoreListActivity diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchViewModel.kt index eb4fe861..271cdee7 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/SearchViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.ui.main.search.model.SearchStoreItem import com.iceteaviet.fastfoodfinder.utils.Constant import com.iceteaviet.fastfoodfinder.utils.getStoreSearchString diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/model/SearchStoreItem.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/model/SearchStoreItem.kt index 176a7517..b6219ddc 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/model/SearchStoreItem.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/search/model/SearchStoreItem.kt @@ -1,6 +1,6 @@ package com.iceteaviet.fastfoodfinder.ui.main.search.model -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store /** * Created by tom on 2019-06-15. diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/routing/MapRoutingActivity.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/routing/MapRoutingActivity.kt index c7c69967..aa730532 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/routing/MapRoutingActivity.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/routing/MapRoutingActivity.kt @@ -28,7 +28,8 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import dagger.hilt.android.AndroidEntryPoint import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.toLatLng import com.iceteaviet.fastfoodfinder.databinding.ActivityMapRoutingBinding import com.iceteaviet.fastfoodfinder.ui.base.BaseActivity import com.iceteaviet.fastfoodfinder.ui.custom.snaphelper.OnSnapListener @@ -282,7 +283,7 @@ class MapRoutingActivity : BaseActivity(), View.OnClickListener { // Directly pull map init data here to render map correctly bypassing event limits val mapData = viewModel.getInitMapData() if (mapData != null) { - addMapMarker(mapData.store.getPosition(), mapData.store.title, mapData.store.address, getStoreLogoDrawableRes(mapData.store.type)) + addMapMarker(mapData.store.toLatLng(), mapData.store.title, mapData.store.address, getStoreLogoDrawableRes(mapData.store.type)) mapData.currLocation?.let { loc -> animateMapCamera(loc, false) addMapMarker(loc, "Your location", "Your current location, please follow the line", R.drawable.ic_map_bluedot) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/routing/MapRoutingViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/routing/MapRoutingViewModel.kt index 7dbd7a4a..222ada1c 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/routing/MapRoutingViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/routing/MapRoutingViewModel.kt @@ -6,7 +6,8 @@ import com.google.maps.android.PolyUtil import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection import com.iceteaviet.fastfoodfinder.data.remote.routing.model.Step -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.toLatLng import com.iceteaviet.fastfoodfinder.utils.isValidLocation import com.iceteaviet.fastfoodfinder.utils.ui.getStoreLogoDrawableRes import dagger.hilt.android.lifecycle.HiltViewModel @@ -63,7 +64,7 @@ class MapRoutingViewModel @Inject constructor() : ViewModel() { } private fun isRoutingDataValid(mapsDirection: MapsDirection, store: Store): Boolean { - if (!isValidLocation(store.getPosition())) return false + if (!isValidLocation(store.toLatLng())) return false if (mapsDirection.routeList.isEmpty() || mapsDirection.routeList[0].legList.isEmpty() || mapsDirection.routeList[0].legList[0].stepList.isEmpty()) { return false @@ -91,7 +92,7 @@ class MapRoutingViewModel @Inject constructor() : ViewModel() { distanceText = mapsDirection.routeList[0].legList[0].getDistance(), summaryText = String.format("Via %s", mapsDirection.routeList[0].summary), event = MapRoutingEvent.AddMapMarker( - currStore.getPosition(), + currStore.toLatLng(), currStore.title, currStore.address, getStoreLogoDrawableRes(currStore.type) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/store/StoreDetailAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/store/StoreDetailAdapter.kt index 2ecdb705..86b2a2c5 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/store/StoreDetailAdapter.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/store/StoreDetailAdapter.kt @@ -16,7 +16,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.request.target.BitmapImageViewTarget import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.ui.custom.roboto.RobotoTextView import com.iceteaviet.fastfoodfinder.utils.getRelativeTimeAgo import com.iceteaviet.fastfoodfinder.utils.getString diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/store/StoreDetailViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/store/StoreDetailViewModel.kt index 85759d57..307f534a 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/store/StoreDetailViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/store/StoreDetailViewModel.kt @@ -14,7 +14,8 @@ import com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository import com.iceteaviet.fastfoodfinder.data.remote.routing.GoogleMapsRoutingApiHelper import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.toLatLng import com.iceteaviet.fastfoodfinder.ui.store.StoreDetailActivity.Companion.KEY_STORE import com.iceteaviet.fastfoodfinder.utils.getCurrentUserHelper import com.iceteaviet.fastfoodfinder.utils.getLatLngString @@ -162,7 +163,7 @@ class StoreDetailViewModel @Inject constructor( fun onNavigationButtonClick() { if (currStore == null) return - val storeLocation = currStore.getPosition() + val storeLocation = currStore.toLatLng() val queries = HashMap() if (!isValidLocation(storeLocation)) { diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/ListDetailViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/ListDetailViewModel.kt index 8131a2f3..2a68433e 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/ListDetailViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/ListDetailViewModel.kt @@ -3,7 +3,7 @@ package com.iceteaviet.fastfoodfinder.ui.storelist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.data.remote.user.model.UserStoreList import com.iceteaviet.fastfoodfinder.utils.ui.getStoreListIconDrawableRes import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListAdapter.kt index d4657661..9ea65f2f 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListAdapter.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListAdapter.kt @@ -9,7 +9,7 @@ import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.utils.ui.getStoreLogoDrawableRes /** diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListViewModel.kt index 254cc4ad..0c53f5fd 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/storelist/StoreListViewModel.kt @@ -3,7 +3,7 @@ package com.iceteaviet.fastfoodfinder.ui.storelist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt index 3be87510..5d2c0cca 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt @@ -5,7 +5,7 @@ package com.iceteaviet.fastfoodfinder.utils import com.google.gson.Gson import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.data.remote.user.model.UserStoreList import com.iceteaviet.fastfoodfinder.ui.ar.model.AugmentedPOI import com.iceteaviet.fastfoodfinder.ui.main.search.model.SearchStoreItem @@ -81,39 +81,39 @@ private fun getRandomDate(): String { fun getFakeCircleKStoreList(): List { val stores = ArrayList() - stores.add(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", "10.770379", "106.68912279999995", "3925 6620", StoreType.TYPE_CIRCLE_K)) - stores.add(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", "10.7721924", "106.69433409999999", "3822 7403", StoreType.TYPE_CIRCLE_K)) + stores.add(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.770379.toDouble(), 106.68912279999995.toDouble(), "3925 6620", StoreType.TYPE_CIRCLE_K)) + stores.add(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.7721924.toDouble(), 106.69433409999999.toDouble(), "3822 7403", StoreType.TYPE_CIRCLE_K)) return stores } fun getFakeStoreList(): List { val stores = ArrayList() - stores.add(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", "10.770379", "106.68912279999995", "3925 6620", StoreType.TYPE_CIRCLE_K)) - stores.add(Store(2, "FamilyMart - Hậu Giang", "973 Hậu Giang, P. 11, Quận 6, TP. HCM", "10.7457782220847", "106.6261117905378", "3755 0439", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(3, "FamilyMart - Nguyễn Lương Bằng", "180 Nguyễn Lương Bằng, P. Tân Phú, Quận 7, TP. HCM", "10.727042", "106.722703", "5417 3390", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(4, "Family Mart - Tạ Quang Bửu", "811 Tạ Quang Bửu, P. 5, Quận 8, TP. HCM", "10.736488", "106.670374", "3835 3193", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(5, "Family Mart - Nguyễn Văn Công", "534 Nguyễn Văn Công, Phường 3, Quận Gò Vấp, TP. HCM", "10.819417", "106.674821", "3835 3193", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(6, "Shop & Go - Phan Đình Phùng", "180 Phan Đình Phùng, P. 2, Quận Phú Nhuận, TP. HCM", "10.7955070000000", "106.6825610000000", "38 353 193", StoreType.TYPE_SHOP_N_GO)) - stores.add(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", "10.7721924", "106.69433409999999", "3822 7403", StoreType.TYPE_CIRCLE_K)) - stores.add(Store(8, "Familymart - Đường D2", "39 Đường D2, P. 25, Quận Bình Thạnh, TP. HCM", "10.80252", "106.715622", "35 126 283", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(9, "FamilyMart - 123 Nguyễn Đình Chiểu", "123 Nguyễn Đình Chiểu, Phường 6, Quận 3, TP. HCM", "10.7775462", "106.6892408999999", "3835 3193", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(10, "FamilyMart - Tôn Dật Tiến", "Tôn Dật Tiên, Quận 7, TP. HCM", "10.723322", "106.71498", "3835 3193", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.770379.toDouble(), 106.68912279999995.toDouble(), "3925 6620", StoreType.TYPE_CIRCLE_K)) + stores.add(Store(2, "FamilyMart - Hậu Giang", "973 Hậu Giang, P. 11, Quận 6, TP. HCM", 10.7457782220847.toDouble(), 106.6261117905378.toDouble(), "3755 0439", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(3, "FamilyMart - Nguyễn Lương Bằng", "180 Nguyễn Lương Bằng, P. Tân Phú, Quận 7, TP. HCM", 10.727042.toDouble(), 106.722703.toDouble(), "5417 3390", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(4, "Family Mart - Tạ Quang Bửu", "811 Tạ Quang Bửu, P. 5, Quận 8, TP. HCM", 10.736488.toDouble(), 106.670374.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(5, "Family Mart - Nguyễn Văn Công", "534 Nguyễn Văn Công, Phường 3, Quận Gò Vấp, TP. HCM", 10.819417.toDouble(), 106.674821.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(6, "Shop & Go - Phan Đình Phùng", "180 Phan Đình Phùng, P. 2, Quận Phú Nhuận, TP. HCM", 10.7955070000000.toDouble(), 106.6825610000000.toDouble(), "38 353 193", StoreType.TYPE_SHOP_N_GO)) + stores.add(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.7721924.toDouble(), 106.69433409999999.toDouble(), "3822 7403", StoreType.TYPE_CIRCLE_K)) + stores.add(Store(8, "Familymart - Đường D2", "39 Đường D2, P. 25, Quận Bình Thạnh, TP. HCM", 10.80252.toDouble(), 106.715622.toDouble(), "35 126 283", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(9, "FamilyMart - 123 Nguyễn Đình Chiểu", "123 Nguyễn Đình Chiểu, Phường 6, Quận 3, TP. HCM", 10.7775462.toDouble(), 106.6892408999999.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(10, "FamilyMart - Tôn Dật Tiến", "Tôn Dật Tiên, Quận 7, TP. HCM", 10.723322.toDouble(), 106.71498.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART)) return stores } fun getFakeSearchStoreItems(): List { val stores = ArrayList() - stores.add(SearchStoreItem(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", "10.770379", "106.68912279999995", "3925 6620", StoreType.TYPE_CIRCLE_K), "")) - stores.add(SearchStoreItem(Store(2, "FamilyMart - Hậu Giang", "973 Hậu Giang, P. 11, Quận 6, TP. HCM", "10.7457782220847", "106.6261117905378", "3755 0439", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(3, "FamilyMart - Nguyễn Lương Bằng", "180 Nguyễn Lương Bằng, P. Tân Phú, Quận 7, TP. HCM", "10.727042", "106.722703", "5417 3390", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(4, "Family Mart - Tạ Quang Bửu", "811 Tạ Quang Bửu, P. 5, Quận 8, TP. HCM", "10.736488", "106.670374", "3835 3193", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(5, "Family Mart - Nguyễn Văn Công", "534 Nguyễn Văn Công, Phường 3, Quận Gò Vấp, TP. HCM", "10.819417", "106.674821", "3835 3193", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(6, "Shop & Go - Phan Đình Phùng", "180 Phan Đình Phùng, P. 2, Quận Phú Nhuận, TP. HCM", "10.7955070000000", "106.6825610000000", "38 353 193", StoreType.TYPE_SHOP_N_GO), "")) - stores.add(SearchStoreItem(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", "10.7721924", "106.69433409999999", "3822 7403", StoreType.TYPE_CIRCLE_K), "")) - stores.add(SearchStoreItem(Store(8, "Familymart - Đường D2", "39 Đường D2, P. 25, Quận Bình Thạnh, TP. HCM", "10.80252", "106.715622", "35 126 283", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(9, "FamilyMart - 123 Nguyễn Đình Chiểu", "123 Nguyễn Đình Chiểu, Phường 6, Quận 3, TP. HCM", "10.7775462", "106.6892408999999", "3835 3193", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(10, "FamilyMart - Tôn Dật Tiến", "Tôn Dật Tiên, Quận 7, TP. HCM", "10.723322", "106.71498", "3835 3193", StoreType.TYPE_FAMILY_MART), "")) + stores.add(SearchStoreItem(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.770379.toDouble(), 106.68912279999995.toDouble(), "3925 6620", StoreType.TYPE_CIRCLE_K), "")) + stores.add(SearchStoreItem(Store(2, "FamilyMart - Hậu Giang", "973 Hậu Giang, P. 11, Quận 6, TP. HCM", 10.7457782220847.toDouble(), 106.6261117905378.toDouble(), "3755 0439", StoreType.TYPE_FAMILY_MART), "")) + stores.add(SearchStoreItem(Store(3, "FamilyMart - Nguyễn Lương Bằng", "180 Nguyễn Lương Bằng, P. Tân Phú, Quận 7, TP. HCM", 10.727042.toDouble(), 106.722703.toDouble(), "5417 3390", StoreType.TYPE_FAMILY_MART), "")) + stores.add(SearchStoreItem(Store(4, "Family Mart - Tạ Quang Bửu", "811 Tạ Quang Bửu, P. 5, Quận 8, TP. HCM", 10.736488.toDouble(), 106.670374.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART), "")) + stores.add(SearchStoreItem(Store(5, "Family Mart - Nguyễn Văn Công", "534 Nguyễn Văn Công, Phường 3, Quận Gò Vấp, TP. HCM", 10.819417.toDouble(), 106.674821.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART), "")) + stores.add(SearchStoreItem(Store(6, "Shop & Go - Phan Đình Phùng", "180 Phan Đình Phùng, P. 2, Quận Phú Nhuận, TP. HCM", 10.7955070000000.toDouble(), 106.6825610000000.toDouble(), "38 353 193", StoreType.TYPE_SHOP_N_GO), "")) + stores.add(SearchStoreItem(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.7721924.toDouble(), 106.69433409999999.toDouble(), "3822 7403", StoreType.TYPE_CIRCLE_K), "")) + stores.add(SearchStoreItem(Store(8, "Familymart - Đường D2", "39 Đường D2, P. 25, Quận Bình Thạnh, TP. HCM", 10.80252.toDouble(), 106.715622.toDouble(), "35 126 283", StoreType.TYPE_FAMILY_MART), "")) + stores.add(SearchStoreItem(Store(9, "FamilyMart - 123 Nguyễn Đình Chiểu", "123 Nguyễn Đình Chiểu, Phường 6, Quận 3, TP. HCM", 10.7775462.toDouble(), 106.6892408999999.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART), "")) + stores.add(SearchStoreItem(Store(10, "FamilyMart - Tôn Dật Tiến", "Tôn Dật Tiên, Quận 7, TP. HCM", 10.723322.toDouble(), 106.71498.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART), "")) return stores } @@ -364,8 +364,8 @@ fun storesToArPoints(stores: List): List { val arPoints = ArrayList() for (i in stores.indices) { arPoints.add(AugmentedPOI(stores[i].title, - stores[i].lat.toDouble(), - stores[i].lng.toDouble(), + stores[i].lat, + stores[i].lng, 0.0, getStoreLogoDrawableRes(stores[i].type))) } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt index 72536efb..614cb579 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt @@ -4,7 +4,7 @@ import android.content.Context import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store object StoreSyncHelper { suspend fun refreshStoresFromRemote(context: Context, clientAuth: ClientAuth, storeRepository: StoreRepository): List { diff --git a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/routing/FakeGoogleMapsRoutingApiHelper.kt b/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/routing/FakeGoogleMapsRoutingApiHelper.kt index 160806f9..5b2c1213 100644 --- a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/routing/FakeGoogleMapsRoutingApiHelper.kt +++ b/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/routing/FakeGoogleMapsRoutingApiHelper.kt @@ -1,7 +1,7 @@ package com.iceteaviet.fastfoodfinder.data.remote.routing import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.utils.exception.NotFoundException import java.util.* diff --git a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt b/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt index 564a00e9..28d1314d 100644 --- a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt +++ b/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt @@ -4,7 +4,7 @@ import android.content.Context import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.utils.StoreType import java.util.TreeMap import kotlin.collections.ArrayList @@ -52,13 +52,13 @@ class FakeFirebaseStoreApiHelper(private val context: Context) : StoreApiHelper val markers = root["markers_add"] as? List> ?: continue for (marker in markers) { stores.add(Store( - id++, - marker["title"].orEmpty(), - marker["address"].orEmpty(), - marker["lat"] ?: "0", - marker["lng"] ?: "0", - marker["tel"].orEmpty(), - type + id = id++, + title = marker["title"].orEmpty(), + address = marker["address"].orEmpty(), + lat = marker["lat"]?.toDoubleOrNull() ?: 0.0, + lng = marker["lng"]?.toDoubleOrNull() ?: 0.0, + tel = marker["tel"].orEmpty(), + type = type, )) } } catch (e: Exception) { diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryFilterTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryFilterTest.kt index 2838fde0..db76fd1c 100644 --- a/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryFilterTest.kt +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryFilterTest.kt @@ -1,7 +1,7 @@ package com.iceteaviet.fastfoodfinder.data.domain.store import com.google.common.truth.Truth.assertThat -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.utils.StoreType import org.junit.Test @@ -46,8 +46,8 @@ class AppStoreRepositoryFilterTest { fun filterInvalidDataTest() { val stores = listOf( Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_LNG, STORE_TEL, STORE_TYPE), - Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, "0.0", STORE_LNG, STORE_TEL, STORE_TYPE), - Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, "0.0", STORE_TEL, STORE_TYPE), + Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, 0.0, STORE_LNG, STORE_TEL, STORE_TYPE), + Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, 0.0, STORE_TEL, STORE_TYPE), Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_LNG, STORE_INVALID_TEL, STORE_TYPE), ) @@ -58,8 +58,8 @@ class AppStoreRepositoryFilterTest { private const val STORE_ID = 123 private const val STORE_TITLE = "store_title" private const val STORE_ADDRESS = "store_address" - private const val STORE_LAT = "10.773996" - private const val STORE_LNG = "106.6898035" + private const val STORE_LAT = 10.773996 + private const val STORE_LNG = 106.6898035 private const val STORE_TEL = "012345678965" private const val STORE_TYPE = StoreType.TYPE_CIRCLE_K @@ -67,7 +67,7 @@ class AppStoreRepositoryFilterTest { private const val STORE_INVALID_ID = -1 private const val STORE_INVALID_TEL = "" private const val STORE_INVALID_ADDRESS = "" - private const val STORE_INVALID_LAT = "-1" - private const val STORE_INVALID_LNG = "-1" + private const val STORE_INVALID_LAT = -1.0 + private const val STORE_INVALID_LNG = -1.0 } } diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt index 27e8d63a..5f701d67 100644 --- a/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt @@ -2,7 +2,7 @@ package com.iceteaviet.fastfoodfinder.utils import com.google.common.truth.Truth.assertThat -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import org.junit.Test class DataUtilsTest { diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/IntentUtilsTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/IntentUtilsTest.kt index fbbee42a..9a1e7b17 100644 --- a/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/IntentUtilsTest.kt +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/IntentUtilsTest.kt @@ -2,7 +2,7 @@ package com.iceteaviet.fastfoodfinder.utils import android.app.Activity import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store import org.mockito.kotlin.verify import org.junit.After import org.junit.Before @@ -120,8 +120,8 @@ class IntentUtilsTest { private const val STORE_ID = 123 private const val STORE_TITLE = "store_title" private const val STORE_ADDRESS = "store_address" - private const val STORE_LAT = "10.773996" - private const val STORE_LNG = "106.6898035" + private const val STORE_LAT = 10.773996 + private const val STORE_LNG = 106.6898035 private const val STORE_TEL = "012345678965" val store = Store(STORE_ID, STORE_TITLE, STORE_ADDRESS, STORE_LAT, STORE_LNG, STORE_TEL, StoreType.TYPE_CIRCLE_K) From 1465d3fcc627053a5b006bf6f9f694b97f6d325b Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 22:49:55 +0700 Subject: [PATCH 04/13] [Data] Implement thread-safe store caching in `AppStoreRepository` - Introduce `Mutex` to synchronize access to `cachedStores` and prevent race conditions. - Wrap `getAllStores`, `refreshStores`, `setStores`, and `deleteAllStores` logic within `cacheMutex.withLock`. - Rename `persistStores` to `persistStoresLocked` to indicate it must be called within a locked context. --- .../data/domain/store/AppStoreRepository.kt | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt index ce7391df..acc2c12b 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt @@ -11,6 +11,8 @@ import com.iceteaviet.fastfoodfinder.utils.exception.NotFoundException import com.iceteaviet.fastfoodfinder.utils.getStoreTypeFromQuery import com.iceteaviet.fastfoodfinder.utils.standardizeDistrictQuery import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val storeDao: StoreDao) : StoreRepository { @@ -18,35 +20,43 @@ class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val @VisibleForTesting internal var cachedStores: List = ArrayList() - override suspend fun getAllStores(): List = withContext(Dispatchers.IO) { - if (cachedStores.isNotEmpty()) { - return@withContext ArrayList(cachedStores) - } + private val cacheMutex = Mutex() - val localStores = storeDao.getAllStores().map { it.toDomain() } - if (localStores.isNotEmpty()) { - cachedStores = localStores - return@withContext ArrayList(localStores) + override suspend fun getAllStores(): List = withContext(Dispatchers.IO) { + cacheMutex.withLock { + if (cachedStores.isNotEmpty()) { + return@withContext ArrayList(cachedStores) + } + + val localStores = storeDao.getAllStores().map { it.toDomain() } + if (localStores.isNotEmpty()) { + cachedStores = localStores + return@withContext ArrayList(localStores) + } + + val remoteStores = storeApiHelper.getAllStores() + val filtered = filterInvalidData(remoteStores) + persistStoresLocked(filtered) + ArrayList(filtered) } - - val remoteStores = storeApiHelper.getAllStores() - val filtered = filterInvalidData(remoteStores) - persistStores(filtered) - ArrayList(filtered) } override suspend fun refreshStores(): List = withContext(Dispatchers.IO) { - val remoteStores = storeApiHelper.getAllStores() - val filtered = filterInvalidData(remoteStores) - persistStores(filtered) - ArrayList(filtered) + cacheMutex.withLock { + val remoteStores = storeApiHelper.getAllStores() + val filtered = filterInvalidData(remoteStores) + persistStoresLocked(filtered) + ArrayList(filtered) + } } override suspend fun setStores(storeList: List) = withContext(Dispatchers.IO) { - persistStores(storeList) + cacheMutex.withLock { + persistStoresLocked(storeList) + } } - private suspend fun persistStores(stores: List) { + private suspend fun persistStoresLocked(stores: List) { cachedStores = stores if (stores.isNotEmpty()) { storeDao.insertAll(stores.map { it.toEntity() }) @@ -84,8 +94,10 @@ class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val } override suspend fun deleteAllStores() = withContext(Dispatchers.IO) { - cachedStores = ArrayList() - storeDao.deleteAll() + cacheMutex.withLock { + cachedStores = ArrayList() + storeDao.deleteAll() + } } override suspend fun getComments(storeId: String): List = withContext(Dispatchers.IO) { From 45eb361486ad3be62b9bece7c6be9301c703b6fc Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 22:51:06 +0700 Subject: [PATCH 05/13] [Data] Refactor `RepositoryModule` to use granular dependency injection for Store components - Update `RepositoryModule` in both `prod` and `mock` flavors to provide `StoreApiHelper` as a separate dependency. - Refactor `provideStoreRepository` to inject `StoreApiHelper` instead of instantiating it internally. - Introduce `provideDatabaseReference` in the `prod` flavor to provide the Firebase `DatabaseReference`. --- .../fastfoodfinder/di/RepositoryModule.kt | 12 +++++++++--- .../fastfoodfinder/di/RepositoryModule.kt | 19 ++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt b/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt index b47cc597..0263b2b6 100644 --- a/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt +++ b/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt @@ -18,6 +18,7 @@ import com.iceteaviet.fastfoodfinder.data.local.prefs.AppPreferencesHelper import com.iceteaviet.fastfoodfinder.data.local.prefs.AppPreferencesWrapper import com.iceteaviet.fastfoodfinder.data.remote.routing.FakeGoogleMapsRoutingApiHelper import com.iceteaviet.fastfoodfinder.data.remote.store.FakeFirebaseStoreApiHelper +import com.iceteaviet.fastfoodfinder.data.remote.store.StoreApiHelper import com.iceteaviet.fastfoodfinder.data.remote.user.FakeFirebaseUserApiHelper import dagger.Module import dagger.Provides @@ -32,9 +33,14 @@ object RepositoryModule { @Provides @Singleton - fun provideStoreRepository(@ApplicationContext context: Context, storeDao: StoreDao): StoreRepository { - val remote = FakeFirebaseStoreApiHelper(context) - return AppStoreRepository(remote, storeDao) + fun provideStoreApiHelper(@ApplicationContext context: Context): StoreApiHelper { + return FakeFirebaseStoreApiHelper(context) + } + + @Provides + @Singleton + fun provideStoreRepository(storeApiHelper: StoreApiHelper, storeDao: StoreDao): StoreRepository { + return AppStoreRepository(storeApiHelper, storeDao) } @Provides diff --git a/app/src/prod/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt b/app/src/prod/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt index 45092960..11193e9e 100644 --- a/app/src/prod/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt +++ b/app/src/prod/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt @@ -1,6 +1,7 @@ package com.iceteaviet.fastfoodfinder.di import android.content.Context +import com.google.firebase.database.DatabaseReference import com.google.firebase.database.FirebaseDatabase import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth @@ -20,6 +21,7 @@ import com.iceteaviet.fastfoodfinder.data.local.prefs.AppPreferencesWrapper import com.iceteaviet.fastfoodfinder.data.local.prefs.AppPreferencesWrapper.Companion.PREFS_NAME import com.iceteaviet.fastfoodfinder.data.remote.routing.GoogleMapsRoutingApiHelper import com.iceteaviet.fastfoodfinder.data.remote.store.FirebaseStoreApiHelper +import com.iceteaviet.fastfoodfinder.data.remote.store.StoreApiHelper import com.iceteaviet.fastfoodfinder.data.remote.user.FirebaseUserApiHelper import dagger.Module import dagger.Provides @@ -34,9 +36,20 @@ object RepositoryModule { @Provides @Singleton - fun provideStoreRepository(storeDao: StoreDao): StoreRepository { - val remote = FirebaseStoreApiHelper(FirebaseDatabase.getInstance().reference) - return AppStoreRepository(remote, storeDao) + fun provideDatabaseReference(): DatabaseReference { + return FirebaseDatabase.getInstance().reference + } + + @Provides + @Singleton + fun provideStoreApiHelper(dbRef: DatabaseReference): StoreApiHelper { + return FirebaseStoreApiHelper(dbRef) + } + + @Provides + @Singleton + fun provideStoreRepository(storeApiHelper: StoreApiHelper, storeDao: StoreDao): StoreRepository { + return AppStoreRepository(storeApiHelper, storeDao) } @Provides From 612b0fb40c7b9aef8ccf410cd12ffb623c2aaa2c Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 22:58:18 +0700 Subject: [PATCH 06/13] [Data] Replace `StoreSyncHelper` with `StoreRefreshService` - Remove `StoreSyncHelper` object and introduce `StoreRefreshService` class to handle store synchronization logic. - Inject `StoreRefreshService` into `SyncDatabaseWorker`, `SettingViewModel`, and `SplashViewModel`, replacing manual dependencies on `ClientAuth` and `StoreRepository` for syncing. - Move bot authentication credentials (email and password) from local method calls to constructor injection within `StoreRefreshService`. - Configure `StoreRefreshService` provision in both `prod` and `mock` Hilt `RepositoryModule` files. - Remove dependency on the global `App` context for store synchronization by utilizing injected credentials and repository instances. --- .../data/domain/store/StoreRefreshService.kt | 25 +++++++++++++++++++ .../service/workers/SyncDatabaseWorker.kt | 10 +++----- .../ui/settings/SettingViewModel.kt | 8 +++--- .../ui/splash/SplashViewModel.kt | 6 ++--- .../fastfoodfinder/utils/StoreSyncHelper.kt | 25 ------------------- .../fastfoodfinder/di/RepositoryModule.kt | 15 +++++++++++ .../fastfoodfinder/di/RepositoryModule.kt | 16 ++++++++++++ 7 files changed, 65 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshService.kt delete mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshService.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshService.kt new file mode 100644 index 00000000..5e2a263a --- /dev/null +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshService.kt @@ -0,0 +1,25 @@ +package com.iceteaviet.fastfoodfinder.data.domain.store + +import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth +import com.iceteaviet.fastfoodfinder.domain.model.Store +import javax.inject.Inject + +class StoreRefreshService @Inject constructor( + private val storeRepository: StoreRepository, + private val clientAuth: ClientAuth, + private val botEmail: String, + private val botPassword: String, +) { + suspend fun refreshStoresFromRemote(): List { + if (clientAuth.isSignedIn()) { + return storeRepository.refreshStores() + } + + try { + clientAuth.signInWithEmailAndPassword(botEmail, botPassword) + return storeRepository.refreshStores() + } finally { + clientAuth.signOut() + } + } +} diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/service/workers/SyncDatabaseWorker.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/service/workers/SyncDatabaseWorker.kt index b600b5e8..f38f9fa4 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/service/workers/SyncDatabaseWorker.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/service/workers/SyncDatabaseWorker.kt @@ -8,21 +8,17 @@ import androidx.work.NetworkType import androidx.work.PeriodicWorkRequest import androidx.work.WorkerParameters import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth -import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository -import com.iceteaviet.fastfoodfinder.utils.StoreSyncHelper +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRefreshService import com.iceteaviet.fastfoodfinder.utils.ui.NotiManager import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext import java.util.concurrent.TimeUnit class SyncDatabaseWorker @AssistedInject constructor( @Assisted val ctx: Context, @Assisted val params: WorkerParameters, - private val storeRepository: StoreRepository, + private val storeRefreshService: StoreRefreshService, private val notiManager: NotiManager, - private val clientAuth: ClientAuth, ) : CoroutineWorker(ctx, params) { override suspend fun doWork(): Result { @@ -32,7 +28,7 @@ class SyncDatabaseWorker @AssistedInject constructor( ) return try { - val storeList = StoreSyncHelper.refreshStoresFromRemote(applicationContext, clientAuth, storeRepository) + val storeList = storeRefreshService.refreshStoresFromRemote() if (storeList.isNotEmpty()) { notiManager.showStoreSyncStatusNotification( diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModel.kt index 99443e22..94553fa6 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModel.kt @@ -2,11 +2,9 @@ package com.iceteaviet.fastfoodfinder.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.iceteaviet.fastfoodfinder.App import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository -import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository -import com.iceteaviet.fastfoodfinder.utils.StoreSyncHelper +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRefreshService import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,7 +31,7 @@ sealed class SettingEvent { class SettingViewModel @Inject constructor( private val clientAuth: ClientAuth, private val preferencesRepository: PreferencesRepository, - private val storeRepository: StoreRepository + private val storeRefreshService: StoreRefreshService, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingUiState()) @@ -71,7 +69,7 @@ class SettingViewModel @Inject constructor( viewModelScope.launch { try { - StoreSyncHelper.refreshStoresFromRemote(App.getContext(), clientAuth, storeRepository) + storeRefreshService.refreshStoresFromRemote() _uiState.value = _uiState.value.copy( showLoadingProgressIndicator = false, diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModel.kt index 6b81e6de..2e6a9706 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModel.kt @@ -2,12 +2,11 @@ package com.iceteaviet.fastfoodfinder.ui.splash import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.iceteaviet.fastfoodfinder.App import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRefreshService import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository -import com.iceteaviet.fastfoodfinder.utils.StoreSyncHelper import com.iceteaviet.fastfoodfinder.utils.exception.EmptyDataException import com.iceteaviet.fastfoodfinder.utils.isValidUserUid import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,6 +30,7 @@ class SplashViewModel @Inject constructor( private val clientAuth: ClientAuth, private val userRepository: UserRepository, private val storeRepository: StoreRepository, + private val storeRefreshService: StoreRefreshService, private val preferencesRepository: PreferencesRepository ) : ViewModel() { @@ -91,7 +91,7 @@ class SplashViewModel @Inject constructor( private suspend fun refreshStoresFromRemote() { try { - val storeList = StoreSyncHelper.refreshStoresFromRemote(App.getContext(), clientAuth, storeRepository) + val storeList = storeRefreshService.refreshStoresFromRemote() if (storeList.isEmpty()) { _uiState.value = SplashUiState.ShowRetryDialog throw EmptyDataException() diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt deleted file mode 100644 index 614cb579..00000000 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.iceteaviet.fastfoodfinder.utils - -import android.content.Context -import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth -import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository -import com.iceteaviet.fastfoodfinder.domain.model.Store - -object StoreSyncHelper { - suspend fun refreshStoresFromRemote(context: Context, clientAuth: ClientAuth, storeRepository: StoreRepository): List { - if (clientAuth.isSignedIn()) { - return storeRepository.refreshStores() - } else { - try { - clientAuth.signInWithEmailAndPassword( - context.getString(R.string.downloader_bot_email), - context.getString(R.string.downloader_bot_pwd) - ) - return storeRepository.refreshStores() - } finally { - clientAuth.signOut() - } - } - } -} diff --git a/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt b/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt index 0263b2b6..11cc715c 100644 --- a/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt +++ b/app/src/mock/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt @@ -7,6 +7,7 @@ import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository import com.iceteaviet.fastfoodfinder.data.domain.routing.AppMapsRoutingRepository import com.iceteaviet.fastfoodfinder.data.domain.routing.MapsRoutingRepository import com.iceteaviet.fastfoodfinder.data.domain.store.AppStoreRepository +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRefreshService import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.domain.user.AppUserRepository import com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository @@ -66,6 +67,20 @@ object RepositoryModule { return AppPreferencesRepository(helper) } + @Provides + @Singleton + fun provideStoreRefreshService( + storeRepository: StoreRepository, + clientAuth: ClientAuth, + ): StoreRefreshService { + return StoreRefreshService( + storeRepository, + clientAuth, + botEmail = "", + botPassword = "", + ) + } + @Provides @Singleton fun provideAuthClient(@ApplicationContext context: Context): ClientAuth { diff --git a/app/src/prod/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt b/app/src/prod/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt index 11193e9e..175b8bd7 100644 --- a/app/src/prod/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt +++ b/app/src/prod/java/com/iceteaviet/fastfoodfinder/di/RepositoryModule.kt @@ -11,6 +11,7 @@ import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository import com.iceteaviet.fastfoodfinder.data.domain.routing.AppMapsRoutingRepository import com.iceteaviet.fastfoodfinder.data.domain.routing.MapsRoutingRepository import com.iceteaviet.fastfoodfinder.data.domain.store.AppStoreRepository +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRefreshService import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.domain.user.AppUserRepository import com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository @@ -74,6 +75,21 @@ object RepositoryModule { return AppPreferencesRepository(helper) } + @Provides + @Singleton + fun provideStoreRefreshService( + storeRepository: StoreRepository, + clientAuth: ClientAuth, + @ApplicationContext context: Context, + ): StoreRefreshService { + return StoreRefreshService( + storeRepository, + clientAuth, + context.getString(R.string.downloader_bot_email), + context.getString(R.string.downloader_bot_pwd), + ) + } + @Provides @Singleton fun provideAuthClient(): ClientAuth { From 73739afc96f05a82671a0bca196ceaf224813b56 Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 23:16:32 +0700 Subject: [PATCH 07/13] [Data] Improve Firebase data fetching and authentication flow - Update `StoreRefreshService` to perform Firebase authentication outside of the `try-finally` block, ensuring `signOut()` is only called if the sign-in attempt was initiated. - Implement cancellation support in `FirebaseStoreApiHelper` by using `invokeOnCancellation` to remove `ValueEventListener` when coroutines are cancelled in `getAllStores` and `getComments`. --- .../data/domain/store/StoreRefreshService.kt | 2 +- .../data/remote/store/FirebaseStoreApiHelper.kt | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshService.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshService.kt index 5e2a263a..58c7dc85 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshService.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshService.kt @@ -15,8 +15,8 @@ class StoreRefreshService @Inject constructor( return storeRepository.refreshStores() } + clientAuth.signInWithEmailAndPassword(botEmail, botPassword) try { - clientAuth.signInWithEmailAndPassword(botEmail, botPassword) return storeRepository.refreshStores() } finally { clientAuth.signOut() diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/FirebaseStoreApiHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/FirebaseStoreApiHelper.kt index 1b6f4cec..6c11e04a 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/FirebaseStoreApiHelper.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/remote/store/FirebaseStoreApiHelper.kt @@ -15,7 +15,8 @@ import kotlin.coroutines.resumeWithException class FirebaseStoreApiHelper(private val databaseRef: DatabaseReference) : StoreApiHelper { override suspend fun getAllStores(): List = suspendCancellableCoroutine { cont -> - databaseRef.child(CHILD_STORES_LOCATION).addListenerForSingleValueEvent(object : ValueEventListener { + val ref = databaseRef.child(CHILD_STORES_LOCATION) + val listener = object : ValueEventListener { override fun onDataChange(dataSnapshot: DataSnapshot) { cont.resume(parseStoresDataFromFirebase(dataSnapshot)) } @@ -23,11 +24,14 @@ class FirebaseStoreApiHelper(private val databaseRef: DatabaseReference) : Store override fun onCancelled(databaseError: DatabaseError) { cont.resumeWithException(databaseError.toException()) } - }) + } + ref.addListenerForSingleValueEvent(listener) + cont.invokeOnCancellation { ref.removeEventListener(listener) } } override suspend fun getComments(storeId: String): List = suspendCancellableCoroutine { cont -> - databaseRef.child(CHILD_COMMENT_LIST).child(storeId).addListenerForSingleValueEvent(object : ValueEventListener { + val ref = databaseRef.child(CHILD_COMMENT_LIST).child(storeId) + val listener = object : ValueEventListener { override fun onCancelled(databaseError: DatabaseError) { cont.resumeWithException(databaseError.toException()) } @@ -35,7 +39,9 @@ class FirebaseStoreApiHelper(private val databaseRef: DatabaseReference) : Store override fun onDataChange(snapshot: DataSnapshot) { cont.resume(parseCommentsDataFromFirebase(snapshot)) } - }) + } + ref.addListenerForSingleValueEvent(listener) + cont.invokeOnCancellation { ref.removeEventListener(listener) } } override fun insertOrUpdateComment(storeId: String, comment: Comment) { From 0d6425db94d1906be35d233f6652256340c4b945 Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 23:16:41 +0700 Subject: [PATCH 08/13] [Data] Synchronize local database with empty results and thread-safe cache clearing - Update `saveToDatabase` to delete all local stores if the provided list is empty, ensuring the local database reflects the current server state. - Refactor `clearCache` to be a `suspend` function and wrap cache clearing in a `cacheMutex.withLock` to ensure thread safety. --- .../data/domain/store/AppStoreRepository.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt index acc2c12b..d3a74258 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepository.kt @@ -60,6 +60,8 @@ class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val cachedStores = stores if (stores.isNotEmpty()) { storeDao.insertAll(stores.map { it.toEntity() }) + } else { + storeDao.deleteAll() } } @@ -108,8 +110,10 @@ class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val storeApiHelper.insertOrUpdateComment(storeId, comment) } - fun clearCache() { - cachedStores = ArrayList() + suspend fun clearCache() { + cacheMutex.withLock { + cachedStores = ArrayList() + } } @VisibleForTesting From d1afea7985690d72301629b683fe31c6921d8e8c Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 23:31:12 +0700 Subject: [PATCH 09/13] [Data] Clean up and optimize `DataUtils.kt` - Remove unused helper functions: `getFakeCircleKStoreList`, `getFakeSearchStoreItems`, `getFakeArPoints`, and `getFakeUserMultiStoreLists`. - Simplify `getFakeStoreList` by removing redundant `.toDouble()` calls on numeric literals. - Refactor loops and conditional logic to use idiomatic Kotlin features like `.forEach`, `when` expressions, and direct returns. - Annotate unused direction-related test helpers with `@Suppress("unused")`. - Clean up unused imports and remove the unused `VALID_PWD_REGEX` constant. --- .../fastfoodfinder/utils/DataUtils.kt | 111 +++++------------- 1 file changed, 29 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt index 5d2c0cca..3e6397f3 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt @@ -5,10 +5,9 @@ package com.iceteaviet.fastfoodfinder.utils import com.google.gson.Gson import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection import com.iceteaviet.fastfoodfinder.data.remote.store.model.Comment -import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.data.remote.user.model.UserStoreList +import com.iceteaviet.fastfoodfinder.domain.model.Store import com.iceteaviet.fastfoodfinder.ui.ar.model.AugmentedPOI -import com.iceteaviet.fastfoodfinder.ui.main.search.model.SearchStoreItem import com.iceteaviet.fastfoodfinder.ui.settings.discountnotify.DiscountNotifyViewModel import com.iceteaviet.fastfoodfinder.utils.ui.getStoreLogoDrawableRes import java.security.SecureRandom @@ -40,7 +39,7 @@ fun getFakeComments(): List { val mediaUrls = arrayOf("http://i.imgur.com/RHdsWRW.jpg", "http://i.imgur.com/IsfQQhd.jpg", "", "http://i.imgur.com/tNu5G5D.jpg", "http://i.imgur.com/QruogAF.jpg") val comments = ArrayList() - for (i in 0 until getRandomInt(3, 5)) { + (0 until getRandomInt(3, 5)).forEach { _ -> val index = getRandomInt(0, 4) comments.add(Comment(names[index], avatars[index], @@ -79,60 +78,21 @@ private fun getRandomDate(): String { } -fun getFakeCircleKStoreList(): List { - val stores = ArrayList() - stores.add(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.770379.toDouble(), 106.68912279999995.toDouble(), "3925 6620", StoreType.TYPE_CIRCLE_K)) - stores.add(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.7721924.toDouble(), 106.69433409999999.toDouble(), "3822 7403", StoreType.TYPE_CIRCLE_K)) - return stores -} - - fun getFakeStoreList(): List { val stores = ArrayList() - stores.add(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.770379.toDouble(), 106.68912279999995.toDouble(), "3925 6620", StoreType.TYPE_CIRCLE_K)) - stores.add(Store(2, "FamilyMart - Hậu Giang", "973 Hậu Giang, P. 11, Quận 6, TP. HCM", 10.7457782220847.toDouble(), 106.6261117905378.toDouble(), "3755 0439", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(3, "FamilyMart - Nguyễn Lương Bằng", "180 Nguyễn Lương Bằng, P. Tân Phú, Quận 7, TP. HCM", 10.727042.toDouble(), 106.722703.toDouble(), "5417 3390", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(4, "Family Mart - Tạ Quang Bửu", "811 Tạ Quang Bửu, P. 5, Quận 8, TP. HCM", 10.736488.toDouble(), 106.670374.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(5, "Family Mart - Nguyễn Văn Công", "534 Nguyễn Văn Công, Phường 3, Quận Gò Vấp, TP. HCM", 10.819417.toDouble(), 106.674821.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.770379, 106.68912279999995, "3925 6620", StoreType.TYPE_CIRCLE_K)) + stores.add(Store(2, "FamilyMart - Hậu Giang", "973 Hậu Giang, P. 11, Quận 6, TP. HCM", 10.7457782220847, 106.6261117905378, "3755 0439", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(3, "FamilyMart - Nguyễn Lương Bằng", "180 Nguyễn Lương Bằng, P. Tân Phú, Quận 7, TP. HCM", 10.727042, 106.722703, "5417 3390", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(4, "Family Mart - Tạ Quang Bửu", "811 Tạ Quang Bửu, P. 5, Quận 8, TP. HCM", 10.736488, 106.670374, "3835 3193", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(5, "Family Mart - Nguyễn Văn Công", "534 Nguyễn Văn Công, Phường 3, Quận Gò Vấp, TP. HCM", 10.819417, 106.674821, "3835 3193", StoreType.TYPE_FAMILY_MART)) stores.add(Store(6, "Shop & Go - Phan Đình Phùng", "180 Phan Đình Phùng, P. 2, Quận Phú Nhuận, TP. HCM", 10.7955070000000.toDouble(), 106.6825610000000.toDouble(), "38 353 193", StoreType.TYPE_SHOP_N_GO)) - stores.add(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.7721924.toDouble(), 106.69433409999999.toDouble(), "3822 7403", StoreType.TYPE_CIRCLE_K)) - stores.add(Store(8, "Familymart - Đường D2", "39 Đường D2, P. 25, Quận Bình Thạnh, TP. HCM", 10.80252.toDouble(), 106.715622.toDouble(), "35 126 283", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(9, "FamilyMart - 123 Nguyễn Đình Chiểu", "123 Nguyễn Đình Chiểu, Phường 6, Quận 3, TP. HCM", 10.7775462.toDouble(), 106.6892408999999.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(10, "FamilyMart - Tôn Dật Tiến", "Tôn Dật Tiên, Quận 7, TP. HCM", 10.723322.toDouble(), 106.71498.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.7721924, 106.69433409999999, "3822 7403", StoreType.TYPE_CIRCLE_K)) + stores.add(Store(8, "Familymart - Đường D2", "39 Đường D2, P. 25, Quận Bình Thạnh, TP. HCM", 10.80252, 106.715622, "35 126 283", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(9, "FamilyMart - 123 Nguyễn Đình Chiểu", "123 Nguyễn Đình Chiểu, Phường 6, Quận 3, TP. HCM", 10.7775462, 106.6892408999999, "3835 3193", StoreType.TYPE_FAMILY_MART)) + stores.add(Store(10, "FamilyMart - Tôn Dật Tiến", "Tôn Dật Tiên, Quận 7, TP. HCM", 10.723322, 106.71498, "3835 3193", StoreType.TYPE_FAMILY_MART)) return stores } -fun getFakeSearchStoreItems(): List { - val stores = ArrayList() - stores.add(SearchStoreItem(Store(1, "Circle K Le Thi Rieng", "148 Le Thi Rieng, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.770379.toDouble(), 106.68912279999995.toDouble(), "3925 6620", StoreType.TYPE_CIRCLE_K), "")) - stores.add(SearchStoreItem(Store(2, "FamilyMart - Hậu Giang", "973 Hậu Giang, P. 11, Quận 6, TP. HCM", 10.7457782220847.toDouble(), 106.6261117905378.toDouble(), "3755 0439", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(3, "FamilyMart - Nguyễn Lương Bằng", "180 Nguyễn Lương Bằng, P. Tân Phú, Quận 7, TP. HCM", 10.727042.toDouble(), 106.722703.toDouble(), "5417 3390", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(4, "Family Mart - Tạ Quang Bửu", "811 Tạ Quang Bửu, P. 5, Quận 8, TP. HCM", 10.736488.toDouble(), 106.670374.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(5, "Family Mart - Nguyễn Văn Công", "534 Nguyễn Văn Công, Phường 3, Quận Gò Vấp, TP. HCM", 10.819417.toDouble(), 106.674821.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(6, "Shop & Go - Phan Đình Phùng", "180 Phan Đình Phùng, P. 2, Quận Phú Nhuận, TP. HCM", 10.7955070000000.toDouble(), 106.6825610000000.toDouble(), "38 353 193", StoreType.TYPE_SHOP_N_GO), "")) - stores.add(SearchStoreItem(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.7721924.toDouble(), 106.69433409999999.toDouble(), "3822 7403", StoreType.TYPE_CIRCLE_K), "")) - stores.add(SearchStoreItem(Store(8, "Familymart - Đường D2", "39 Đường D2, P. 25, Quận Bình Thạnh, TP. HCM", 10.80252.toDouble(), 106.715622.toDouble(), "35 126 283", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(9, "FamilyMart - 123 Nguyễn Đình Chiểu", "123 Nguyễn Đình Chiểu, Phường 6, Quận 3, TP. HCM", 10.7775462.toDouble(), 106.6892408999999.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART), "")) - stores.add(SearchStoreItem(Store(10, "FamilyMart - Tôn Dật Tiến", "Tôn Dật Tiên, Quận 7, TP. HCM", 10.723322.toDouble(), 106.71498.toDouble(), "3835 3193", StoreType.TYPE_FAMILY_MART), "")) - return stores -} - -fun getFakeArPoints(): List { - val arPoints = ArrayList() - arPoints.add(AugmentedPOI("Circle K Le Thi Rieng", 10.770379, 106.68912279999995, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_CIRCLE_K))) - arPoints.add(AugmentedPOI("FamilyMart - Hậu Giang", 10.7457782220847, 106.6261117905378, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_FAMILY_MART))) - arPoints.add(AugmentedPOI("FamilyMart - Nguyễn Lương Bằng", 10.727042, 106.722703, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_FAMILY_MART))) - arPoints.add(AugmentedPOI("Family Mart - Tạ Quang Bửu", 10.736488, 106.670374, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_FAMILY_MART))) - arPoints.add(AugmentedPOI("Family Mart - Nguyễn Văn Công", 10.819417, 106.674821, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_FAMILY_MART))) - arPoints.add(AugmentedPOI("Shop & Go - Phan Đình Phùng", 10.7955070000000, 106.6825610000000, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_SHOP_N_GO))) - arPoints.add(AugmentedPOI("Circle K Ly Tu Trong", 10.7721924, 106.69433409999999, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_CIRCLE_K))) - arPoints.add(AugmentedPOI("Familymart - Đường D2", 10.80252, 106.715622, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_FAMILY_MART))) - arPoints.add(AugmentedPOI("FamilyMart - 123 Nguyễn Đình Chiểu", 10.7775462, 106.6892408999999, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_FAMILY_MART))) - arPoints.add(AugmentedPOI("FamilyMart - Tôn Dật Tiến", 10.723322, 106.71498, 0.0, getStoreLogoDrawableRes(StoreType.TYPE_FAMILY_MART))) - - return arPoints -} - fun getFakeStoreIds(): List { val storeIds = ArrayList() storeIds.add(1) @@ -151,17 +111,7 @@ fun getFakeUserStoreLists(): List { return userStoreLists } -fun getFakeUserMultiStoreLists(): List { - val userStoreLists = ArrayList() - - userStoreLists.add(UserStoreList(0, getFakeStoreIds(), 1, "My Saved Places")) - userStoreLists.add(UserStoreList(1, getFakeStoreIds(), 2, "My Favourite Places")) - userStoreLists.add(UserStoreList(2, getFakeStoreIds(), 3, "My Third List")) - userStoreLists.add(UserStoreList(3, getFakeStoreIds(), 4, "My Forth List")) - - return userStoreLists -} - +@Suppress("unused") fun getFakeMapsDirection(): MapsDirection { val directionData = "{\n" + " \"routes\": [\n" + @@ -255,6 +205,7 @@ fun getFakeMapsDirection(): MapsDirection { return Gson().fromJson(directionData, MapsDirection::class.java) } +@Suppress("unused") fun getFakeEmptyLegMapsDirection(): MapsDirection { val directionData = "{\n" + " \"routes\": [\n" + @@ -270,6 +221,7 @@ fun getFakeEmptyLegMapsDirection(): MapsDirection { return Gson().fromJson(directionData, MapsDirection::class.java) } +@Suppress("unused") fun getFakeEmptyStepMapsDirection(): MapsDirection { val directionData = "{\n" + " \"routes\": [\n" + @@ -319,18 +271,14 @@ fun getRandomLong(): Long { * Get store type by keyword */ fun getStoreType(key: String?): Int { - return if (key == "circle_k") - StoreType.TYPE_CIRCLE_K - else if (key == "mini_stop") - StoreType.TYPE_MINI_STOP - else if (key == "family_mart") - StoreType.TYPE_FAMILY_MART - else if (key == "bsmart") - StoreType.TYPE_BSMART - else if (key == "711") - StoreType.TYPE_7_ELEVEN - else - StoreType.TYPE_CIRCLE_K + return when (key) { + "circle_k" -> StoreType.TYPE_CIRCLE_K + "mini_stop" -> StoreType.TYPE_MINI_STOP + "family_mart" -> StoreType.TYPE_FAMILY_MART + "bsmart" -> StoreType.TYPE_BSMART + "711" -> StoreType.TYPE_7_ELEVEN + else -> StoreType.TYPE_CIRCLE_K + } } fun getStoreSearchString(storeType: Int): String { @@ -355,7 +303,6 @@ fun isValidEmail(email: String): Boolean { return matcher.find() } -val VALID_PWD_REGEX: Pattern = Pattern.compile("^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#\$%^&+=])(?=\\S+\$).{8,}\$\n", Pattern.CASE_INSENSITIVE) fun isValidPassword(password: String): Boolean { return password.trim().length >= 8 } @@ -373,12 +320,12 @@ fun storesToArPoints(stores: List): List { } fun getStoreNameByKey(key: String?): String { - when (key) { - DiscountNotifyViewModel.KEY_CIRCLE_K -> return "Cirle K" - DiscountNotifyViewModel.KEY_BSMART -> return "B\u2019s mart" - DiscountNotifyViewModel.KEY_FAMILY_MART -> return "Family mart" - DiscountNotifyViewModel.KEY_MINI_STOP -> return "Ministop" - DiscountNotifyViewModel.KEY_711 -> return "7-Eleven" - else -> return "" + return when (key) { + DiscountNotifyViewModel.KEY_CIRCLE_K -> "Cirle K" + DiscountNotifyViewModel.KEY_BSMART -> "B\u2019s mart" + DiscountNotifyViewModel.KEY_FAMILY_MART -> "Family mart" + DiscountNotifyViewModel.KEY_MINI_STOP -> "Ministop" + DiscountNotifyViewModel.KEY_711 -> "7-Eleven" + else -> "" } } From ca1bb19d9e1951c335031b48fb1e20eb8474d236 Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Tue, 21 Apr 2026 23:48:50 +0700 Subject: [PATCH 10/13] [Map] Refactor map UI with clustering, event flow, and startup behavior - Replace manual marker management with ClusterManager for better performance and automatic marker clustering - Replace MutableStateFlow event stream with SharedFlow to fix event conflation on rapid successive emissions - Fix startup flash by hiding map until first camera animation - Fix resume re-centering by not resetting zoom-to-user flag on resume - Fix configuration change causing blank map via savedInstanceState guard - Add 4-second location timeout with fallback to persisted last-known location (or default), saved to SharedPreferences on every GPS update - Fix NearByStoreAdapter dual-list bug and DiffUtil item identity - Make NearByStore an immutable data class, cache LatLng in cluster items - Remove dead code (onClearOldMapData, SetupMapEventHandlers, animateMarker) --- .../domain/prefs/AppPreferencesRepository.kt | 8 + .../domain/prefs/PreferencesRepository.kt | 4 + .../data/local/prefs/AppPreferencesHelper.kt | 22 +++ .../data/local/prefs/PreferencesHelper.kt | 4 + .../ui/main/map/MainMapFragment.kt | 164 ++++++------------ .../ui/main/map/MainMapViewModel.kt | 162 +++++++++-------- .../ui/main/map/NearByStoreAdapter.kt | 32 +--- .../ui/main/map/cluster/StoreClusterItem.kt | 20 +++ .../main/map/cluster/StoreClusterRenderer.kt | 21 +++ .../ui/main/map/model/NearByStore.kt | 19 +- .../fastfoodfinder/utils/ui/UiUtils.kt | 20 --- 11 files changed, 236 insertions(+), 240 deletions(-) create mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/cluster/StoreClusterItem.kt create mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/cluster/StoreClusterRenderer.kt diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/prefs/AppPreferencesRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/prefs/AppPreferencesRepository.kt index efc45a85..f98830f6 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/prefs/AppPreferencesRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/prefs/AppPreferencesRepository.kt @@ -52,4 +52,12 @@ class AppPreferencesRepository(private val preferencesHelper: PreferencesHelper) override fun setIfLanguageIsVietnamese(isVietnamese: Boolean) { preferencesHelper.setIfLanguageIsVietnamese(isVietnamese) } + + override fun getLastKnownLocation(): Pair? { + return preferencesHelper.getLastKnownLocation() + } + + override fun setLastKnownLocation(lat: Double, lng: Double) { + preferencesHelper.setLastKnownLocation(lat, lng) + } } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/prefs/PreferencesRepository.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/prefs/PreferencesRepository.kt index 14cb6aef..9e80e3e7 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/prefs/PreferencesRepository.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/prefs/PreferencesRepository.kt @@ -19,4 +19,8 @@ interface PreferencesRepository { fun getIfLanguageIsVietnamese(): Boolean fun setIfLanguageIsVietnamese(isVietnamese: Boolean) + + fun getLastKnownLocation(): Pair? + + fun setLastKnownLocation(lat: Double, lng: Double) } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/prefs/AppPreferencesHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/prefs/AppPreferencesHelper.kt index 550fe628..57eca8d6 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/prefs/AppPreferencesHelper.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/prefs/AppPreferencesHelper.kt @@ -35,6 +35,22 @@ class AppPreferencesHelper(private val preferences: PreferencesWrapper) : Prefer preferences.putBoolean(KEY_LANGUAGE, isVietnamese) } + override fun getLastKnownLocation(): Pair? { + val latStr = preferences.getString(KEY_LAST_KNOWN_LAT, "") + val lngStr = preferences.getString(KEY_LAST_KNOWN_LNG, "") + if (latStr.isEmpty() || lngStr.isEmpty()) return null + return try { + Pair(latStr.toDouble(), lngStr.toDouble()) + } catch (e: NumberFormatException) { + null + } + } + + override fun setLastKnownLocation(lat: Double, lng: Double) { + preferences.putString(KEY_LAST_KNOWN_LAT, lat.toString()) + preferences.putString(KEY_LAST_KNOWN_LNG, lng.toString()) + } + companion object { @VisibleForTesting const val KEY_APP_LAUNCH_FIRST_TIME = "app_launch_first_time" @@ -47,5 +63,11 @@ class AppPreferencesHelper(private val preferences: PreferencesWrapper) : Prefer @VisibleForTesting const val KEY_LANGUAGE = "lang" + + @VisibleForTesting + const val KEY_LAST_KNOWN_LAT = "last_known_lat" + + @VisibleForTesting + const val KEY_LAST_KNOWN_LNG = "last_known_lng" } } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/prefs/PreferencesHelper.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/prefs/PreferencesHelper.kt index efcdc536..d3f723af 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/prefs/PreferencesHelper.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/local/prefs/PreferencesHelper.kt @@ -15,4 +15,8 @@ interface PreferencesHelper { fun getIfLanguageIsVietnamese(): Boolean fun setIfLanguageIsVietnamese(isVietnamese: Boolean) + + fun getLastKnownLocation(): Pair? + + fun setLastKnownLocation(lat: Double, lng: Double) } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapFragment.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapFragment.kt index 94d0d10e..bbd103d5 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapFragment.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapFragment.kt @@ -8,7 +8,6 @@ import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import android.widget.Toast -import androidx.collection.SparseArrayCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -22,16 +21,16 @@ import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.SupportMapFragment import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng -import com.google.android.gms.maps.model.Marker -import com.google.android.gms.maps.model.MarkerOptions import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.maps.android.clustering.ClusterManager import dagger.hilt.android.AndroidEntryPoint import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.core.location.GoogleLocationManager import com.iceteaviet.fastfoodfinder.data.remote.routing.model.MapsDirection import com.iceteaviet.fastfoodfinder.domain.model.Store -import com.iceteaviet.fastfoodfinder.domain.model.toLatLng import com.iceteaviet.fastfoodfinder.databinding.FragmentMainMapBinding +import com.iceteaviet.fastfoodfinder.ui.main.map.cluster.StoreClusterItem +import com.iceteaviet.fastfoodfinder.ui.main.map.cluster.StoreClusterRenderer import com.iceteaviet.fastfoodfinder.ui.main.map.model.NearByStore import com.iceteaviet.fastfoodfinder.ui.main.map.storeinfo.StoreInfoDialog import com.iceteaviet.fastfoodfinder.utils.Constant @@ -40,9 +39,7 @@ import com.iceteaviet.fastfoodfinder.utils.REQUEST_LOCATION import com.iceteaviet.fastfoodfinder.utils.isLocationPermissionGranted import com.iceteaviet.fastfoodfinder.utils.openRoutingActivity import com.iceteaviet.fastfoodfinder.utils.requestLocationPermission -import com.iceteaviet.fastfoodfinder.utils.ui.animateMarker -import com.iceteaviet.fastfoodfinder.utils.ui.getStoreIcon -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @AndroidEntryPoint @@ -58,8 +55,8 @@ class MainMapFragment : Fragment() { private var googleMap: GoogleMap? = null private var mMapFragment: SupportMapFragment? = null private var nearByStoreAdapter: NearByStoreAdapter? = null - - private var markerSparseArray = SparseArrayCompat() + private var clusterManager: ClusterManager? = null + private var mapRevealed = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -74,7 +71,7 @@ class MainMapFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupUI() + setupUI(savedInstanceState) mMapFragment = inflateSupportMapFragment() setupObservers() } @@ -87,73 +84,46 @@ class MainMapFragment : Fragment() { private fun setupObservers() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collectLatest { state -> - when (state) { - is MainMapEvent.Idle -> {} + viewModel.uiEvent.collect { event -> + when (event) { is MainMapEvent.RequestLocationPermission -> { requestLocationPermission() - viewModel.markEventConsumed() } is MainMapEvent.SetMyLocationEnabled -> { - setMyLocationEnabled(state.enabled) - viewModel.markEventConsumed() + setMyLocationEnabled(event.enabled) } is MainMapEvent.AnimateMapCamera -> { - animateMapCamera(state.location, state.zoomToDetail) - viewModel.markEventConsumed() + animateMapCamera(event.location, event.zoomToDetail) } is MainMapEvent.ShowWarningMessage -> { - showWarningMessage(state.stringId) - viewModel.markEventConsumed() + showWarningMessage(event.stringId) } is MainMapEvent.ShowGeneralErrorMessage -> { showGeneralErrorMessage() - viewModel.markEventConsumed() } is MainMapEvent.ShowCannotGetLocationMessage -> { showCannotGetLocationMessage() - viewModel.markEventConsumed() } is MainMapEvent.ShowInvalidStoreLocationWarning -> { showInvalidStoreLocationWarning() - viewModel.markEventConsumed() } - is MainMapEvent.AddMarkersToMap -> { - addMarkersToMap(state.stores) - viewModel.markEventConsumed() + is MainMapEvent.AddStoresToCluster -> { + addStoresToCluster(event.stores) } is MainMapEvent.SetupMap -> { setupMap() - viewModel.markEventConsumed() - } - is MainMapEvent.SetupMapEventHandlers -> { - setupMapEventHandlers() - viewModel.markEventConsumed() } is MainMapEvent.ShowMapRoutingView -> { - showMapRoutingView(state.store, state.mapsDirection) - viewModel.markEventConsumed() + showMapRoutingView(event.store, event.mapsDirection) } is MainMapEvent.ShowDialogStoreInfo -> { - showDialogStoreInfo(state.store) - viewModel.markEventConsumed() - } - is MainMapEvent.AnimateMapMarker -> { - val targetMarker = markerSparseArray.get(state.storeId) - animateMapMarker(targetMarker, state.storeType) - viewModel.markEventConsumed() + showDialogStoreInfo(event.store) } is MainMapEvent.SetNearByStores -> { - setNearByStores(state.stores) - viewModel.markEventConsumed() + setNearByStores(event.stores) } is MainMapEvent.ClearNearByStores -> { clearNearByStores() - viewModel.markEventConsumed() - } - is MainMapEvent.ClearMapData -> { - clearMapData() - viewModel.markEventConsumed() } } } @@ -190,6 +160,10 @@ class MainMapFragment : Fragment() { } private fun animateMapCamera(location: LatLng, zoomToDetail: Boolean) { + if (!mapRevealed) { + binding.mapPlaceholder.visibility = View.VISIBLE + mapRevealed = true + } val zoomLevel = if (zoomToDetail) Constant.DETAILED_ZOOM_LEVEL else DEFAULT_ZOOM_LEVEL googleMap?.animateCamera(CameraUpdateFactory.newLatLngZoom(location, zoomLevel)) } @@ -210,67 +184,52 @@ class MainMapFragment : Fragment() { Toast.makeText(context, R.string.cannot_get_curr_location, Toast.LENGTH_SHORT).show() } - private fun addMarkersToMap(storeList: List) { - if (googleMap == null) return - - viewModel.onClearOldMapData() - - viewLifecycleOwner.lifecycleScope.launch(kotlinx.coroutines.Dispatchers.Main) { - val bounds = googleMap?.projection?.visibleRegion?.latLngBounds - for (i in storeList.indices) { - val store = storeList[i] - if (bounds != null && !bounds.contains(store.toLatLng())) continue - - val marker = googleMap!!.addMarker( - MarkerOptions().position(store.toLatLng()) - .title(store.title) - .snippet(store.address) - .icon(getStoreIcon(resources, store.type, -1, -1)) - ) - if (marker != null) { - marker.tag = store - markerSparseArray.put(store.id, marker) - } - - // Prevent `addMarker` IPC bottlenecking on massive datasets by yielding the main thread - if (i > 0 && i % 20 == 0) { - kotlinx.coroutines.yield() - } - } - } + private fun addStoresToCluster(stores: List) { + val cm = clusterManager ?: return + cm.clearItems() + cm.addItems(stores.map { StoreClusterItem(it) }) + cm.cluster() } @SuppressLint("MissingPermission") private fun setupMap() { if (googleMap != null) return - mMapFragment?.getMapAsync { googleMap -> - this.googleMap = googleMap - googleMap.isBuildingsEnabled = true + mMapFragment?.getMapAsync { map -> + this.googleMap = map + map.isBuildingsEnabled = true + + val cm = ClusterManager(requireContext(), map) + cm.renderer = StoreClusterRenderer(requireContext(), map, cm) + this.clusterManager = cm + + map.setOnCameraIdleListener(cm) + map.setOnMarkerClickListener(cm) + + cm.setOnClusterItemClickListener { item -> + showDialogStoreInfo(item.getStore()) + true + } - googleMap.setOnCameraMoveListener { + map.setOnCameraMoveListener { viewModel.onMapCameraMove( - googleMap.cameraPosition.target, - googleMap.projection.visibleRegion.latLngBounds + map.cameraPosition.target, + map.projection.visibleRegion.latLngBounds ) } + map.setOnCameraMoveStartedListener { + viewModel.onCameraMoveStarted() + } + viewModel.onGetMapAsync() } } - private fun setupMapEventHandlers() { - setMarkersListener(googleMap) - } - private fun showMapRoutingView(currStore: Store, mapsDirection: MapsDirection) { openRoutingActivity(requireActivity(), currStore, mapsDirection) } - private fun animateMapMarker(marker: Marker?, storeType: Int) { - animateMarker(resources, marker, storeType) - } - private fun setNearByStores(nearbyStores: List) { nearByStoreAdapter?.setStores(nearbyStores) } @@ -279,11 +238,6 @@ class MainMapFragment : Fragment() { nearByStoreAdapter?.clearData() } - private fun clearMapData() { - markerSparseArray.clear() - googleMap?.clear() - } - private fun inflateSupportMapFragment(): SupportMapFragment? { val fragmentManager = childFragmentManager val fragment = fragmentManager.findFragmentById(R.id.maps_container) @@ -308,10 +262,16 @@ class MainMapFragment : Fragment() { } } - private fun setupUI() { + private fun setupUI(savedInstanceState: Bundle?) { mNearStoreRecyclerView = binding.rvBottomSheet mBottomSheetContainer = binding.llBottomSheet + if (savedInstanceState == null) { + binding.mapPlaceholder.visibility = View.INVISIBLE + } else { + mapRevealed = true + } + nearByStoreAdapter = NearByStoreAdapter() initBottomSheet() } @@ -323,21 +283,11 @@ class MainMapFragment : Fragment() { nearByStoreAdapter?.setOnStoreListListener(object : NearByStoreAdapter.StoreListListener { override fun onItemClick(store: Store) { - viewModel.onNavigationButtonClick(store) + viewModel.onNearByStoreClicked(store) } }) } - private fun setMarkersListener(googleMap: GoogleMap?) { - googleMap?.setOnMarkerClickListener { marker -> - val store = marker.tag as Store? - if (store != null) { - showDialogStoreInfo(store) - } - false - } - } - private fun showDialogStoreInfo(store: Store) { val dialog = StoreInfoDialog.newInstance(store) dialog.setDialogListen(object : StoreInfoDialog.StoreDialogActionListener { @@ -361,4 +311,4 @@ class MainMapFragment : Fragment() { return fragment } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt index eca67802..e8443847 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModel.kt @@ -1,6 +1,5 @@ package com.iceteaviet.fastfoodfinder.ui.main.map -import androidx.collection.SparseArrayCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.gms.maps.model.LatLng @@ -9,6 +8,7 @@ import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.core.location.LatLngAlt import com.iceteaviet.fastfoodfinder.core.location.LocationListener import com.iceteaviet.fastfoodfinder.core.location.base.ILocationManager +import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository import com.iceteaviet.fastfoodfinder.data.domain.routing.MapsRoutingRepository import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository import com.iceteaviet.fastfoodfinder.data.remote.routing.GoogleMapsRoutingApiHelper @@ -25,11 +25,13 @@ import com.iceteaviet.fastfoodfinder.utils.getLatLngString import com.iceteaviet.fastfoodfinder.utils.isValidLocation import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -37,7 +39,6 @@ import kotlinx.coroutines.launch import javax.inject.Inject sealed class MainMapEvent { - object Idle : MainMapEvent() object RequestLocationPermission : MainMapEvent() data class SetMyLocationEnabled(val enabled: Boolean) : MainMapEvent() data class AnimateMapCamera(val location: LatLng, val zoomToDetail: Boolean) : MainMapEvent() @@ -45,15 +46,12 @@ sealed class MainMapEvent { object ShowGeneralErrorMessage : MainMapEvent() object ShowCannotGetLocationMessage : MainMapEvent() object ShowInvalidStoreLocationWarning : MainMapEvent() - data class AddMarkersToMap(val stores: List) : MainMapEvent() + data class AddStoresToCluster(val stores: List) : MainMapEvent() object SetupMap : MainMapEvent() - object SetupMapEventHandlers : MainMapEvent() data class ShowMapRoutingView(val store: Store, val mapsDirection: MapsDirection) : MainMapEvent() data class ShowDialogStoreInfo(val store: Store) : MainMapEvent() - data class AnimateMapMarker(val storeId: Int, val storeType: Int) : MainMapEvent() data class SetNearByStores(val stores: List) : MainMapEvent() object ClearNearByStores : MainMapEvent() - object ClearMapData : MainMapEvent() } @OptIn(FlowPreview::class) @@ -61,7 +59,8 @@ sealed class MainMapEvent { class MainMapViewModel @Inject constructor( private val storeRepository: StoreRepository, private val mapsRoutingRepository: MapsRoutingRepository, - private val searchEventBus: SearchEventBus + private val searchEventBus: SearchEventBus, + private val preferencesRepository: PreferencesRepository ) : ViewModel(), LocationListener { private lateinit var locationManager: ILocationManager @@ -70,19 +69,19 @@ class MainMapViewModel @Inject constructor( this.locationManager = mgr } - private val _uiState = MutableStateFlow(MainMapEvent.Idle as MainMapEvent) - val uiState: StateFlow = _uiState.asStateFlow() + private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 128) + val uiEvent: SharedFlow = _uiEvent.asSharedFlow() private var currLocation: LatLng? = null private var storeList: List = ArrayList() - private var visibleStores: List = ArrayList() private var isZoomToUser = false private var locationGranted = false private var isStoresLoaded = false + private var locationTimeoutJob: Job? = null + private val cameraPositionFlow = MutableSharedFlow(extraBufferCapacity = 64) - private val newVisibleStoreFlow = MutableSharedFlow(extraBufferCapacity = 64) init { setupFlows() @@ -92,23 +91,12 @@ class MainMapViewModel @Inject constructor( cameraPositionFlow .debounce(200) .map { position -> - val stores = getVisibleStore(storeList, position.cameraBounds) - stores.forEach { store -> - if (!visibleStores.contains(store)) { - newVisibleStoreFlow.tryEmit(store) - } - } - visibleStores = stores - generateNearByStoresWithDistance(position.cameraPosition, stores) + val visible = getVisibleStore(storeList, position.cameraBounds) + generateNearByStoresWithDistance(position.cameraPosition, visible) } + .flowOn(kotlinx.coroutines.Dispatchers.Default) .onEach { nearbyStores -> - _uiState.value = MainMapEvent.SetNearByStores(nearbyStores) - } - .launchIn(viewModelScope) - - newVisibleStoreFlow - .onEach { store -> - _uiState.value = MainMapEvent.AnimateMapMarker(store.id, store.type) + _uiEvent.tryEmit(MainMapEvent.SetNearByStores(nearbyStores)) } .launchIn(viewModelScope) } @@ -119,7 +107,7 @@ class MainMapViewModel @Inject constructor( resetState() if (!hasLocationPermission) { - _uiState.value = MainMapEvent.RequestLocationPermission + _uiEvent.tryEmit(MainMapEvent.RequestLocationPermission) } else { onLocationPermissionGranted() } @@ -131,14 +119,19 @@ class MainMapViewModel @Inject constructor( isBusRegistered = true } - _uiState.value = MainMapEvent.SetupMap + _uiEvent.tryEmit(MainMapEvent.SetupMap) if (!isStoresLoaded) { loadAllStoresToMap() } + + if (!isZoomToUser) { + startLocationTimeout() + } } override fun onCleared() { + locationTimeoutJob?.cancel() if (::locationManager.isInitialized) { unsubscribeLocationUpdate() } @@ -150,7 +143,7 @@ class MainMapViewModel @Inject constructor( subscribeLocationUpdate() requestCurrentLocation() } - _uiState.value = MainMapEvent.SetMyLocationEnabled(true) + _uiEvent.tryEmit(MainMapEvent.SetMyLocationEnabled(true)) locationGranted = true } @@ -167,23 +160,29 @@ class MainMapViewModel @Inject constructor( } override fun onLocationFailed(type: Int) { + locationTimeoutJob?.cancel() + if (!isZoomToUser) { + fallbackToLastKnownOrDefault() + } } fun onMapCameraMove(cameraPosition: LatLng, bounds: LatLngBounds) { cameraPositionFlow.tryEmit(MapCameraPosition(cameraPosition, bounds)) } + fun onCameraMoveStarted() { + _uiEvent.tryEmit(MainMapEvent.ClearNearByStores) + } + fun onGetMapAsync() { if (storeList.isNotEmpty()) { - _uiState.value = MainMapEvent.AddMarkersToMap(storeList) + _uiEvent.tryEmit(MainMapEvent.AddStoresToCluster(storeList)) } if (locationGranted) { - _uiState.value = MainMapEvent.SetMyLocationEnabled(true) + _uiEvent.tryEmit(MainMapEvent.SetMyLocationEnabled(true)) requestCurrentLocation() } - - _uiState.value = MainMapEvent.SetupMapEventHandlers } fun onNavigationButtonClick(store: Store) { @@ -191,12 +190,12 @@ class MainMapViewModel @Inject constructor( val queries = HashMap() if (!isValidLocation(storeLocation)) { - _uiState.value = MainMapEvent.ShowInvalidStoreLocationWarning + _uiEvent.tryEmit(MainMapEvent.ShowInvalidStoreLocationWarning) return } if (!isValidLocation(currLocation)) { - _uiState.value = MainMapEvent.ShowCannotGetLocationMessage + _uiEvent.tryEmit(MainMapEvent.ShowCannotGetLocationMessage) return } @@ -210,18 +209,19 @@ class MainMapViewModel @Inject constructor( try { val mapsDirection = mapsRoutingRepository.getMapsDirection(queries, store) if (mapsDirection.routeList.isNotEmpty()) { - _uiState.value = MainMapEvent.ShowMapRoutingView(store, mapsDirection) + _uiEvent.tryEmit(MainMapEvent.ShowMapRoutingView(store, mapsDirection)) } else { - _uiState.value = MainMapEvent.ShowGeneralErrorMessage + _uiEvent.tryEmit(MainMapEvent.ShowGeneralErrorMessage) } } catch (e: Exception) { - _uiState.value = MainMapEvent.ShowGeneralErrorMessage + _uiEvent.tryEmit(MainMapEvent.ShowGeneralErrorMessage) } } } - fun onClearOldMapData() { - _uiState.value = MainMapEvent.ClearMapData + fun onNearByStoreClicked(store: Store) { + _uiEvent.tryEmit(MainMapEvent.AnimateMapCamera(store.toLatLng(), true)) + _uiEvent.tryEmit(MainMapEvent.ShowDialogStoreInfo(store)) } private fun onSearchResult(searchEventResult: SearchEventResult) { @@ -241,13 +241,12 @@ class MainMapViewModel @Inject constructor( handleSearchStoreClickAction(it) } } - else -> _uiState.value = MainMapEvent.ShowGeneralErrorMessage + else -> _uiEvent.tryEmit(MainMapEvent.ShowGeneralErrorMessage) } } private fun resetState() { locationGranted = false - isZoomToUser = false } private fun subscribeLocationUpdate() { @@ -260,14 +259,37 @@ class MainMapViewModel @Inject constructor( } private fun onCurrLocationChanged(latitude: Double, longitude: Double) { - currLocation = LatLng(latitude, longitude).also { + currLocation = LatLng(latitude, longitude) + preferencesRepository.setLastKnownLocation(latitude, longitude) + + if (!isZoomToUser) { + locationTimeoutJob?.cancel() + _uiEvent.tryEmit(MainMapEvent.AnimateMapCamera(currLocation!!, false)) + isZoomToUser = true + } + } + + private fun startLocationTimeout() { + locationTimeoutJob?.cancel() + locationTimeoutJob = viewModelScope.launch { + delay(LOCATION_TIMEOUT_MS) if (!isZoomToUser) { - _uiState.value = MainMapEvent.AnimateMapCamera(it, false) - isZoomToUser = true + fallbackToLastKnownOrDefault() } } } + private fun fallbackToLastKnownOrDefault() { + isZoomToUser = true + val persisted = preferencesRepository.getLastKnownLocation() + val target = if (persisted != null) { + LatLng(persisted.first, persisted.second) + } else { + Constant.DEFAULT_MAP_TARGET + } + _uiEvent.tryEmit(MainMapEvent.AnimateMapCamera(target, false)) + } + private fun getVisibleStore(storeList: List, bounds: LatLngBounds): List { val stores = ArrayList() for (i in storeList.indices) { @@ -280,11 +302,9 @@ class MainMapViewModel @Inject constructor( } private fun generateNearByStoresWithDistance(currPos: LatLng, stores: List): List { - val res = ArrayList() - for (store in stores) { - res.add(NearByStore(store, distanceBetween(currPos, store.toLatLng()))) - } - return res + return stores.map { store -> + NearByStore(store, distanceBetween(currPos, store.toLatLng())) + }.sortedBy { it.distance } } private fun handleSearchQuickAction(storeType: Int) { @@ -292,14 +312,14 @@ class MainMapViewModel @Inject constructor( try { val foundStores = storeRepository.findStoresByType(storeType) if (foundStores.isEmpty()) { - _uiState.value = MainMapEvent.ShowWarningMessage(R.string.store_not_found) + _uiEvent.tryEmit(MainMapEvent.ShowWarningMessage(R.string.store_not_found)) } else { storeList = foundStores - _uiState.value = MainMapEvent.AddMarkersToMap(storeList) - _uiState.value = MainMapEvent.AnimateMapCamera(storeList[0].toLatLng(), false) + _uiEvent.tryEmit(MainMapEvent.AddStoresToCluster(storeList)) + _uiEvent.tryEmit(MainMapEvent.AnimateMapCamera(storeList[0].toLatLng(), false)) } } catch (e: Exception) { - _uiState.value = MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed) + _uiEvent.tryEmit(MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed)) } } } @@ -309,14 +329,14 @@ class MainMapViewModel @Inject constructor( try { val foundStores = storeRepository.findStores(searchString) if (foundStores.isEmpty()) { - _uiState.value = MainMapEvent.ShowWarningMessage(R.string.store_not_found) + _uiEvent.tryEmit(MainMapEvent.ShowWarningMessage(R.string.store_not_found)) } else { storeList = foundStores - _uiState.value = MainMapEvent.AddMarkersToMap(storeList) - _uiState.value = MainMapEvent.AnimateMapCamera(storeList[0].toLatLng(), false) + _uiEvent.tryEmit(MainMapEvent.AddStoresToCluster(storeList)) + _uiEvent.tryEmit(MainMapEvent.AnimateMapCamera(storeList[0].toLatLng(), false)) } } catch (e: Exception) { - _uiState.value = MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed) + _uiEvent.tryEmit(MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed)) } } } @@ -324,7 +344,7 @@ class MainMapViewModel @Inject constructor( private fun handleSearchCollapseAction() { loadAllStoresToMap() currLocation?.let { - _uiState.value = MainMapEvent.AnimateMapCamera(it, false) + _uiEvent.tryEmit(MainMapEvent.AnimateMapCamera(it, false)) } } @@ -335,25 +355,25 @@ class MainMapViewModel @Inject constructor( storeList = allStores isStoresLoaded = true if (storeList.isEmpty()) { - _uiState.value = MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed) + _uiEvent.tryEmit(MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed)) } else { - _uiState.value = MainMapEvent.AddMarkersToMap(storeList) + _uiEvent.tryEmit(MainMapEvent.AddStoresToCluster(storeList)) } } catch (e: Exception) { - _uiState.value = MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed) + _uiEvent.tryEmit(MainMapEvent.ShowWarningMessage(R.string.get_store_data_failed)) } } } private fun handleSearchStoreClickAction(store: Store) { storeList = arrayListOf(store) - _uiState.value = MainMapEvent.AddMarkersToMap(storeList) - _uiState.value = MainMapEvent.AnimateMapCamera(store.toLatLng(), false) - _uiState.value = MainMapEvent.ClearNearByStores - _uiState.value = MainMapEvent.ShowDialogStoreInfo(store) + _uiEvent.tryEmit(MainMapEvent.AddStoresToCluster(storeList)) + _uiEvent.tryEmit(MainMapEvent.AnimateMapCamera(store.toLatLng(), false)) + _uiEvent.tryEmit(MainMapEvent.ClearNearByStores) + _uiEvent.tryEmit(MainMapEvent.ShowDialogStoreInfo(store)) } - fun markEventConsumed() { - _uiState.value = MainMapEvent.Idle + companion object { + private const val LOCATION_TIMEOUT_MS = 4000L } } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/NearByStoreAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/NearByStoreAdapter.kt index e61d48c2..ef1c7807 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/NearByStoreAdapter.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/NearByStoreAdapter.kt @@ -16,38 +16,21 @@ import com.iceteaviet.fastfoodfinder.ui.main.map.model.NearByStore import com.iceteaviet.fastfoodfinder.utils.formatDistance import com.iceteaviet.fastfoodfinder.utils.ui.getStoreLogoDrawableRes -/** - * Created by tom on 7/21/18. - */ class NearByStoreAdapter @JvmOverloads internal constructor(diffCallback: DiffUtil.ItemCallback = DIFF_CALLBACK) : ListAdapter(diffCallback) { - /** - * Views Ref - */ private lateinit var binding: ItemStoreBinding - - private val nearByStores: MutableList private var listener: StoreListListener? = null - init { - nearByStores = ArrayList() - } - fun setOnStoreListListener(listener: StoreListListener) { this.listener = listener } - // FIXME: Called too many times fun setStores(nearbyStores: List) { - nearByStores.clear() - nearByStores.addAll(nearbyStores) - - submitList(nearbyStores) // DiffUtil takes care of the check + submitList(nearbyStores) } fun clearData() { - nearByStores.clear() - submitList(nearByStores) + submitList(emptyList()) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StoreViewHolder { @@ -72,8 +55,9 @@ class NearByStoreAdapter @JvmOverloads internal constructor(diffCallback: DiffUt init { itemView.setOnClickListener { - if (listener != null && adapterPosition >= 0) - listener.onItemClick(nearByStores[adapterPosition].store) + val pos = adapterPosition + if (listener != null && pos >= 0) + listener.onItemClick(getItem(pos).store) } } @@ -90,12 +74,12 @@ class NearByStoreAdapter @JvmOverloads internal constructor(diffCallback: DiffUt companion object { private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: NearByStore, newItem: NearByStore): Boolean { - return oldItem.store.id == newItem.store.id && oldItem.distance == newItem.distance + return oldItem.store.id == newItem.store.id } override fun areContentsTheSame(oldItem: NearByStore, newItem: NearByStore): Boolean { - return oldItem.store.address == newItem.store.address && oldItem.distance == newItem.distance + return oldItem == newItem } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/cluster/StoreClusterItem.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/cluster/StoreClusterItem.kt new file mode 100644 index 00000000..6d307684 --- /dev/null +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/cluster/StoreClusterItem.kt @@ -0,0 +1,20 @@ +package com.iceteaviet.fastfoodfinder.ui.main.map.cluster + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.ClusterItem +import com.iceteaviet.fastfoodfinder.domain.model.Store + +class StoreClusterItem(private val store: Store) : ClusterItem { + + private val position = LatLng(store.lat, store.lng) + + override fun getPosition(): LatLng = position + + override fun getTitle(): String = store.title + + override fun getSnippet(): String = store.address + + override fun getZIndex(): Float? = null + + fun getStore(): Store = store +} diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/cluster/StoreClusterRenderer.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/cluster/StoreClusterRenderer.kt new file mode 100644 index 00000000..5abade08 --- /dev/null +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/cluster/StoreClusterRenderer.kt @@ -0,0 +1,21 @@ +package com.iceteaviet.fastfoodfinder.ui.main.map.cluster + +import android.content.Context +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.MarkerOptions +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.clustering.view.DefaultClusterRenderer +import com.iceteaviet.fastfoodfinder.utils.ui.getStoreIcon + +class StoreClusterRenderer( + private val ctx: Context, + map: GoogleMap, + clusterManager: ClusterManager, +) : DefaultClusterRenderer(ctx, map, clusterManager) { + + override fun onBeforeClusterItemRendered(item: StoreClusterItem, markerOptions: MarkerOptions) { + markerOptions.icon(getStoreIcon(ctx.resources, item.getStore().type, -1, -1)) + .title(item.title) + .snippet(item.snippet) + } +} diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/model/NearByStore.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/model/NearByStore.kt index ccd91499..fe114a01 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/model/NearByStore.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/map/model/NearByStore.kt @@ -2,21 +2,4 @@ package com.iceteaviet.fastfoodfinder.ui.main.map.model import com.iceteaviet.fastfoodfinder.domain.model.Store -/** - * Created by tom on 2019-04-29. - */ -class NearByStore(var store: Store, var distance: Double) { - override fun equals(other: Any?): Boolean { - return if (other is NearByStore) { - store.equals(other.store) && distance.equals(other.distance) - } else { - false - } - } - - override fun hashCode(): Int { - var result = store.hashCode() - result = 31 * result + distance.hashCode() - return result - } -} \ No newline at end of file +data class NearByStore(val store: Store, val distance: Double) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtils.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtils.kt index 5091ca06..a5ee4718 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtils.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtils.kt @@ -2,19 +2,16 @@ package com.iceteaviet.fastfoodfinder.utils.ui -import android.animation.ValueAnimator import android.content.res.Resources import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.drawable.Drawable -import android.view.animation.BounceInterpolator import androidx.annotation.DrawableRes import androidx.annotation.VisibleForTesting import androidx.collection.LruCache import androidx.core.content.ContextCompat import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.BitmapDescriptorFactory -import com.google.android.gms.maps.model.Marker import com.iceteaviet.fastfoodfinder.App import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.utils.StoreType @@ -60,23 +57,6 @@ fun getDirectionImage(direction: String?): Int { } } -/** - * Animate marker icon - */ -fun animateMarker(resources: Resources, marker: Marker?, storeType: Int) { - if (marker == null) - return - - // Disable computationally expensive scaling animation that causes map freezing - // Instead, immediately set the icon to its final full size (75x75) - try { - marker.setIcon(getStoreIcon(resources, storeType, 75, 75)) - } catch (ex: IllegalArgumentException) { - ex.printStackTrace() - } -} - - private var cache: LruCache = LruCache(((Runtime.getRuntime().maxMemory() / 1024 / 8).toInt())) private var originalBitmapCache = HashMap() From 716e98b5a5d5ec255a6f9be045128f73355af111 Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 17:14:47 +0700 Subject: [PATCH 11/13] [Store] Update 711 logo resource in search fragment - Replace `@drawable/logo_711_50` with `@drawable/logo_711_red` in `fragment_search.xml`. --- app/src/main/res/layout/fragment_search.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index a3e72ab4..fdf13747 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -87,7 +87,7 @@ android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" - android:src="@drawable/logo_711_50" + android:src="@drawable/logo_711_red" app:civ_border_color="@color/googleRed" app:civ_circle_background_color="@color/googleRed" /> From c4d5321a59317b642f6b43ba8c2cd208ea1123b0 Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 23:06:29 +0700 Subject: [PATCH 12/13] [Store] Remove "Shop & Go" store type and related data - Comment out `TYPE_SHOP_N_GO` in `StoreType.kt`. - Remove `TYPE_SHOP_N_GO` logo mapping in `UiUtils.kt` and corresponding unit tests in `UiUtilsTest.kt`. - Remove sample "Shop & Go" store entries from `DataUtils.kt`. - Remove `shopngo.json` mapping from `FakeFirebaseStoreApiHelper.kt`. --- .../main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt | 1 - .../main/java/com/iceteaviet/fastfoodfinder/utils/StoreType.kt | 2 +- .../main/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtils.kt | 1 - .../data/remote/store/FakeFirebaseStoreApiHelper.kt | 1 - .../java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtilsTest.kt | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt index 3e6397f3..74b7b711 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/DataUtils.kt @@ -85,7 +85,6 @@ fun getFakeStoreList(): List { stores.add(Store(3, "FamilyMart - Nguyễn Lương Bằng", "180 Nguyễn Lương Bằng, P. Tân Phú, Quận 7, TP. HCM", 10.727042, 106.722703, "5417 3390", StoreType.TYPE_FAMILY_MART)) stores.add(Store(4, "Family Mart - Tạ Quang Bửu", "811 Tạ Quang Bửu, P. 5, Quận 8, TP. HCM", 10.736488, 106.670374, "3835 3193", StoreType.TYPE_FAMILY_MART)) stores.add(Store(5, "Family Mart - Nguyễn Văn Công", "534 Nguyễn Văn Công, Phường 3, Quận Gò Vấp, TP. HCM", 10.819417, 106.674821, "3835 3193", StoreType.TYPE_FAMILY_MART)) - stores.add(Store(6, "Shop & Go - Phan Đình Phùng", "180 Phan Đình Phùng, P. 2, Quận Phú Nhuận, TP. HCM", 10.7955070000000.toDouble(), 106.6825610000000.toDouble(), "38 353 193", StoreType.TYPE_SHOP_N_GO)) stores.add(Store(7, "Circle K Ly Tu Trong", "238 Ly Tu Trong, Ben Thanh Ward, District 1, Ho Chi Minh, Vietnam", 10.7721924, 106.69433409999999, "3822 7403", StoreType.TYPE_CIRCLE_K)) stores.add(Store(8, "Familymart - Đường D2", "39 Đường D2, P. 25, Quận Bình Thạnh, TP. HCM", 10.80252, 106.715622, "35 126 283", StoreType.TYPE_FAMILY_MART)) stores.add(Store(9, "FamilyMart - 123 Nguyễn Đình Chiểu", "123 Nguyễn Đình Chiểu, Phường 6, Quận 3, TP. HCM", 10.7775462, 106.6892408999999, "3835 3193", StoreType.TYPE_FAMILY_MART)) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreType.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreType.kt index 67edfa80..844b677c 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreType.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreType.kt @@ -8,6 +8,6 @@ object StoreType { const val TYPE_MINI_STOP = 1 const val TYPE_FAMILY_MART = 2 const val TYPE_BSMART = 3 - const val TYPE_SHOP_N_GO = 4 + //const val TYPE_SHOP_N_GO = 4 const val TYPE_7_ELEVEN = 5 } \ No newline at end of file diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtils.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtils.kt index a5ee4718..5a787c05 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtils.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtils.kt @@ -31,7 +31,6 @@ fun getStoreLogoDrawableRes(type: Int): Int { StoreType.TYPE_MINI_STOP -> R.drawable.logo_ministop_50 StoreType.TYPE_FAMILY_MART -> R.drawable.logo_familymart_50 StoreType.TYPE_BSMART -> R.drawable.logo_bsmart_50 - StoreType.TYPE_SHOP_N_GO -> R.drawable.logo_shopngo_50 StoreType.TYPE_7_ELEVEN -> R.drawable.logo_711_50 else -> R.drawable.logo_circlek_50 } diff --git a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt b/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt index 28d1314d..c98f4f56 100644 --- a/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt +++ b/app/src/mock/java/com/iceteaviet/fastfoodfinder/data/remote/store/FakeFirebaseStoreApiHelper.kt @@ -38,7 +38,6 @@ class FakeFirebaseStoreApiHelper(private val context: Context) : StoreApiHelper "ministop.json" to StoreType.TYPE_MINI_STOP, "familymart.json" to StoreType.TYPE_FAMILY_MART, "bsmart.json" to StoreType.TYPE_BSMART, - "shopngo.json" to StoreType.TYPE_SHOP_N_GO, "711.json" to StoreType.TYPE_7_ELEVEN, ) diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtilsTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtilsTest.kt index c1fc53f1..11f43f9a 100644 --- a/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtilsTest.kt +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/ui/UiUtilsTest.kt @@ -15,7 +15,6 @@ class UiUtilsTest { assertThat(getStoreLogoDrawableRes(StoreType.TYPE_BSMART)).isEqualTo(R.drawable.logo_bsmart_50) assertThat(getStoreLogoDrawableRes(StoreType.TYPE_FAMILY_MART)).isEqualTo(R.drawable.logo_familymart_50) assertThat(getStoreLogoDrawableRes(StoreType.TYPE_MINI_STOP)).isEqualTo(R.drawable.logo_ministop_50) - assertThat(getStoreLogoDrawableRes(StoreType.TYPE_SHOP_N_GO)).isEqualTo(R.drawable.logo_shopngo_50) } @Test From 36b13ed6a50707311d76dc34da453b45ab0c0ac0 Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Wed, 22 Apr 2026 21:59:07 +0700 Subject: [PATCH 13/13] [Test] Add comprehensive unit tests for data and UI layers - Add unit tests for `AppStoreRepository` covering caching, database fallback, remote fetching, and data filtering. - Add unit tests for `StoreRefreshService` to verify authentication-aware store synchronization logic. - Add unit tests for `MainMapViewModel`, `SettingViewModel`, and `SplashViewModel` using `kotlinx-coroutines-test` and Mockito. - Add mapping tests for `StoreEntity` and `StoreDto` to ensure correct domain model conversion. - Expand `AppPreferencesHelperTest` with test cases for last known location persistence. - Add `kotlinx-coroutines-test` dependency to `libs.versions.toml`. --- .../domain/store/AppStoreRepositoryTest.kt | 185 ++++++++++++ .../domain/store/StoreRefreshServiceTest.kt | 81 +++++ .../db/store/model/StoreEntityMappingTest.kt | 85 ++++++ .../local/prefs/AppPreferencesHelperTest.kt | 64 ++++ .../data/remote/store/model/StoreDtoTest.kt | 71 +++++ .../ui/main/map/MainMapViewModelTest.kt | 276 ++++++++++++++++++ .../ui/settings/SettingViewModelTest.kt | 158 ++++++++++ .../ui/splash/SplashViewModelTest.kt | 181 ++++++++++++ gradle/libs.versions.toml | 4 +- 9 files changed, 1104 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryTest.kt create mode 100644 app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshServiceTest.kt create mode 100644 app/src/test/java/com/iceteaviet/fastfoodfinder/data/local/db/store/model/StoreEntityMappingTest.kt create mode 100644 app/src/test/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/StoreDtoTest.kt create mode 100644 app/src/test/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModelTest.kt create mode 100644 app/src/test/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModelTest.kt create mode 100644 app/src/test/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModelTest.kt diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryTest.kt new file mode 100644 index 00000000..3ca3de44 --- /dev/null +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/AppStoreRepositoryTest.kt @@ -0,0 +1,185 @@ +package com.iceteaviet.fastfoodfinder.data.domain.store + +import com.iceteaviet.fastfoodfinder.data.local.db.store.StoreDao +import com.iceteaviet.fastfoodfinder.data.local.db.store.model.StoreEntity +import com.iceteaviet.fastfoodfinder.data.remote.store.StoreApiHelper +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class AppStoreRepositoryTest { + + private val storeApiHelper: StoreApiHelper = mock() + private val storeDao: StoreDao = mock() + + private val repository = AppStoreRepository(storeApiHelper, storeDao) + + @Test + fun getAllStores_returnsCachedStores_whenCacheIsNotEmpty() = runTest { + val cachedStore = createStore(1) + repository.cachedStores = listOf(cachedStore) + + val result = repository.getAllStores() + + assertThat(result).hasSize(1) + assertThat(result[0]).isEqualTo(cachedStore) + } + + @Test + fun getAllStores_returnsDatabaseStores_whenCacheIsEmptyAndDbHasData() = runTest { + val entity = createStoreEntity(1) + whenever(storeDao.getAllStores()).thenReturn(listOf(entity)) + + val result = repository.getAllStores() + + assertThat(result).hasSize(1) + assertThat(result[0].id).isEqualTo(1) + } + + @Test + fun getAllStores_fallsBackToRemote_whenCacheAndDbAreEmpty() = runTest { + val remoteStore = createStore(1) + whenever(storeDao.getAllStores()).thenReturn(emptyList()) + whenever(storeApiHelper.getAllStores()).thenReturn(listOf(remoteStore)) + + val result = repository.getAllStores() + + assertThat(result).hasSize(1) + } + + @Test + fun getAllStores_filtersInvalidData_fromRemote() = runTest { + val validStore = createStore(1) + val invalidStore = Store(id = -1, title = "bad", address = "addr", lat = 10.0, lng = 106.0) + whenever(storeDao.getAllStores()).thenReturn(emptyList()) + whenever(storeApiHelper.getAllStores()).thenReturn(listOf(validStore, invalidStore)) + + val result = repository.getAllStores() + + assertThat(result).hasSize(1) + assertThat(result[0].id).isEqualTo(1) + } + + @Test + fun refreshStores_alwaysFetchesFromRemote() = runTest { + val remoteStore = createStore(1) + repository.cachedStores = listOf(createStore(99)) + whenever(storeApiHelper.getAllStores()).thenReturn(listOf(remoteStore)) + + val result = repository.refreshStores() + + assertThat(result).hasSize(1) + assertThat(result[0].id).isEqualTo(1) + } + + @Test + fun refreshStores_persistsToDatabase() = runTest { + val remoteStore = createStore(1) + whenever(storeApiHelper.getAllStores()).thenReturn(listOf(remoteStore)) + + repository.refreshStores() + + verify(storeDao).insertAll(org.mockito.kotlin.any()) + } + + @Test + fun setStores_withNonEmptyList_insertsAll() = runTest { + val stores = listOf(createStore(1), createStore(2)) + + repository.setStores(stores) + + verify(storeDao).insertAll(org.mockito.kotlin.any()) + } + + @Test + fun setStores_withEmptyList_deletesAll() = runTest { + repository.setStores(emptyList()) + + verify(storeDao).deleteAll() + } + + @Test + fun deleteAllStores_clearsCacheAndDeletesFromDb() = runTest { + repository.cachedStores = listOf(createStore(1)) + + repository.deleteAllStores() + + assertThat(repository.cachedStores).isEmpty() + verify(storeDao).deleteAll() + } + + @Test + fun clearCache_onlyClearsCacheNotDb() = runTest { + repository.cachedStores = listOf(createStore(1)) + + repository.clearCache() + + assertThat(repository.cachedStores).isEmpty() + } + + @Test + fun findStoreById_returnsStore_whenFound() = runTest { + val entity = createStoreEntity(42) + whenever(storeDao.findStoreById(42)).thenReturn(entity) + + val result = repository.findStoreById(42) + + assertThat(result.id).isEqualTo(42) + } + + @Test(expected = Exception::class) + fun findStoreById_throws_whenNotFound() = runTest { + whenever(storeDao.findStoreById(999)).thenReturn(null) + + repository.findStoreById(999) + } + + @Test + fun findStoresByType_delegatesToDao() = runTest { + val entity = createStoreEntity(1) + whenever(storeDao.findStoresByType(2)).thenReturn(listOf(entity)) + + val result = repository.findStoresByType(2) + + assertThat(result).hasSize(1) + } + + @Test + fun findStoresByIds_delegatesToDao() = runTest { + val entity = createStoreEntity(1) + whenever(storeDao.findStoresByIds(listOf(1, 2))).thenReturn(listOf(entity)) + + val result = repository.findStoresByIds(listOf(1, 2)) + + assertThat(result).hasSize(1) + } + + @Test + fun getAllStores_returnsDefensiveCopy() = runTest { + repository.cachedStores = listOf(createStore(1)) + + val result = repository.getAllStores() + repository.cachedStores = listOf(createStore(2)) + + assertThat(result).hasSize(1) + assertThat(result[0].id).isEqualTo(1) + } + + companion object { + private fun createStore(id: Int) = Store( + id = id, title = "Store $id", address = "Address $id", + lat = 10.0 + id * 0.01, lng = 106.0 + id * 0.01, + tel = "090000000$id", type = 1 + ) + + private fun createStoreEntity(id: Int) = StoreEntity( + id = id, type = 1, title = "Store $id", address = "Address $id", + latitude = 10.0 + id * 0.01, longitude = 106.0 + id * 0.01, + telephone = "090000000$id" + ) + } +} diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshServiceTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshServiceTest.kt new file mode 100644 index 00000000..de2f055e --- /dev/null +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshServiceTest.kt @@ -0,0 +1,81 @@ +package com.iceteaviet.fastfoodfinder.data.domain.store + +import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class StoreRefreshServiceTest { + + private val storeRepository: StoreRepository = mock() + private val clientAuth: ClientAuth = mock() + + private lateinit var service: StoreRefreshService + + private val testStores = listOf( + Store(id = 1, title = "Test", address = "Addr", lat = 10.0, lng = 106.0) + ) + + @Before + fun setUp() { + service = StoreRefreshService( + storeRepository, clientAuth, + botEmail = "bot@test.com", botPassword = "botpass" + ) + } + + @Test + fun refreshStoresFromRemote_refreshesDirectly_whenAlreadySignedIn() = runTest { + whenever(clientAuth.isSignedIn()).thenReturn(true) + whenever(storeRepository.refreshStores()).thenReturn(testStores) + + val result = service.refreshStoresFromRemote() + + assertThat(result).isEqualTo(testStores) + verify(storeRepository).refreshStores() + verify(clientAuth, never()).signInWithEmailAndPassword(org.mockito.kotlin.any(), org.mockito.kotlin.any()) + verify(clientAuth, never()).signOut() + } + + @Test + fun refreshStoresFromRemote_signsInRefreshesAndSignsOut_whenNotSignedIn() = runTest { + whenever(clientAuth.isSignedIn()).thenReturn(false) + whenever(storeRepository.refreshStores()).thenReturn(testStores) + + val result = service.refreshStoresFromRemote() + + assertThat(result).isEqualTo(testStores) + verify(clientAuth).signInWithEmailAndPassword("bot@test.com", "botpass") + verify(storeRepository).refreshStores() + verify(clientAuth).signOut() + } + + @Test + fun refreshStoresFromRemote_signsOutInFinally_whenRefreshThrows() = runTest { + whenever(clientAuth.isSignedIn()).thenReturn(false) + whenever(storeRepository.refreshStores()).thenThrow(RuntimeException("network error")) + + try { + service.refreshStoresFromRemote() + } catch (_: RuntimeException) { + } + + verify(clientAuth).signOut() + } + + @Test + fun refreshStoresFromRemote_doesNotSignIn_whenAlreadySignedIn() = runTest { + whenever(clientAuth.isSignedIn()).thenReturn(true) + whenever(storeRepository.refreshStores()).thenReturn(testStores) + + service.refreshStoresFromRemote() + + verify(clientAuth, never()).signInWithEmailAndPassword(org.mockito.kotlin.any(), org.mockito.kotlin.any()) + } +} diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/data/local/db/store/model/StoreEntityMappingTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/local/db/store/model/StoreEntityMappingTest.kt new file mode 100644 index 00000000..801e48f5 --- /dev/null +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/local/db/store/model/StoreEntityMappingTest.kt @@ -0,0 +1,85 @@ +package com.iceteaviet.fastfoodfinder.data.local.db.store.model + +import com.google.common.truth.Truth.assertThat +import com.iceteaviet.fastfoodfinder.domain.model.Store +import org.junit.Test + +class StoreEntityMappingTest { + + @Test + fun toDomain_mapsEntityFieldsToDomain() { + val entity = StoreEntity( + id = 1, type = 2, title = "Circle K", + address = "123 D1", latitude = 10.773996, + longitude = 106.6898035, telephone = "0900000001" + ) + + val store = entity.toDomain() + + assertThat(store.id).isEqualTo(1) + assertThat(store.type).isEqualTo(2) + assertThat(store.title).isEqualTo("Circle K") + assertThat(store.address).isEqualTo("123 D1") + assertThat(store.lat).isWithin(0.0001).of(10.773996) + assertThat(store.lng).isWithin(0.0001).of(106.6898035) + assertThat(store.tel).isEqualTo("0900000001") + } + + @Test + fun toEntity_mapsDomainFieldsToEntity() { + val store = Store( + id = 1, title = "Circle K", address = "123 D1", + lat = 10.773996, lng = 106.6898035, + tel = "0900000001", type = 2 + ) + + val entity = store.toEntity() + + assertThat(entity.id).isEqualTo(1) + assertThat(entity.type).isEqualTo(2) + assertThat(entity.title).isEqualTo("Circle K") + assertThat(entity.address).isEqualTo("123 D1") + assertThat(entity.latitude).isWithin(0.0001).of(10.773996) + assertThat(entity.longitude).isWithin(0.0001).of(106.6898035) + assertThat(entity.telephone).isEqualTo("0900000001") + } + + @Test + fun roundTrip_domainToEntityAndBack_preservesAllFields() { + val original = Store( + id = 42, title = "FamilyMart", address = "456 D2", + lat = 10.78, lng = 106.70, tel = "0902", type = 3 + ) + + val roundTripped = original.toEntity().toDomain() + + assertThat(roundTripped).isEqualTo(original) + } + + @Test + fun toDomain_handlesDefaults() { + val entity = StoreEntity() + + val store = entity.toDomain() + + assertThat(store.id).isEqualTo(0) + assertThat(store.title).isEmpty() + assertThat(store.lat).isWithin(0.001).of(0.0) + assertThat(store.lng).isWithin(0.001).of(0.0) + assertThat(store.tel).isEmpty() + assertThat(store.type).isEqualTo(0) + } + + @Test + fun fieldNames_mapCorrectly_latToLatitude_lngToLongitude_telToTelephone() { + val store = Store( + id = 1, lat = 10.0, lng = 106.0, tel = "123", type = 0, title = "T", address = "A" + ) + + val entity = store.toEntity() + + assertThat(entity.latitude).isEqualTo(store.lat) + assertThat(entity.longitude).isEqualTo(store.lng) + assertThat(entity.telephone).isEqualTo(store.tel) + } +} diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/data/local/prefs/AppPreferencesHelperTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/local/prefs/AppPreferencesHelperTest.kt index dc519491..13249702 100644 --- a/app/src/test/java/com/iceteaviet/fastfoodfinder/data/local/prefs/AppPreferencesHelperTest.kt +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/local/prefs/AppPreferencesHelperTest.kt @@ -158,6 +158,70 @@ class AppPreferencesHelperTest { return AppPreferencesHelper(mockBrokenPreferencesWrapper) } + @Test + fun getLastKnownLocation_returnsNull_whenEmptyStrings() { + Mockito.`when`(mockPreferencesWrapper.getString(eq(AppPreferencesHelper.KEY_LAST_KNOWN_LAT), anyString())) + .thenReturn("") + Mockito.`when`(mockPreferencesWrapper.getString(eq(AppPreferencesHelper.KEY_LAST_KNOWN_LNG), anyString())) + .thenReturn("") + + val result = mockPreferencesHelper.getLastKnownLocation() + + assertThat(result).isNull() + } + + @Test + fun getLastKnownLocation_returnsNull_whenNonNumeric() { + Mockito.`when`(mockPreferencesWrapper.getString(eq(AppPreferencesHelper.KEY_LAST_KNOWN_LAT), anyString())) + .thenReturn("not_a_number") + Mockito.`when`(mockPreferencesWrapper.getString(eq(AppPreferencesHelper.KEY_LAST_KNOWN_LNG), anyString())) + .thenReturn("106.689") + + val result = mockPreferencesHelper.getLastKnownLocation() + + assertThat(result).isNull() + } + + @Test + fun getLastKnownLocation_returnsCoordinates_whenValid() { + Mockito.`when`(mockPreferencesWrapper.getString(eq(AppPreferencesHelper.KEY_LAST_KNOWN_LAT), anyString())) + .thenReturn("10.773996") + Mockito.`when`(mockPreferencesWrapper.getString(eq(AppPreferencesHelper.KEY_LAST_KNOWN_LNG), anyString())) + .thenReturn("106.6898035") + + val result = mockPreferencesHelper.getLastKnownLocation() + + assertThat(result).isNotNull() + assertThat(result!!.first).isCloseTo(10.773996, org.assertj.core.data.Offset.offset(0.0001)) + assertThat(result.second).isCloseTo(106.6898035, org.assertj.core.data.Offset.offset(0.0001)) + } + + @Test + fun getLastKnownLocation_returnsNull_whenLatEmptyAndLngValid() { + Mockito.`when`(mockPreferencesWrapper.getString(eq(AppPreferencesHelper.KEY_LAST_KNOWN_LAT), anyString())) + .thenReturn("") + Mockito.`when`(mockPreferencesWrapper.getString(eq(AppPreferencesHelper.KEY_LAST_KNOWN_LNG), anyString())) + .thenReturn("106.689") + + val result = mockPreferencesHelper.getLastKnownLocation() + + assertThat(result).isNull() + } + + @Test + fun setLastKnownLocation_storesAsString() { + mockPreferencesHelper.setLastKnownLocation(10.5, 107.0) + + Mockito.verify(mockPreferencesWrapper).putString( + eq(AppPreferencesHelper.KEY_LAST_KNOWN_LAT), eq("10.5") + ) + Mockito.verify(mockPreferencesWrapper).putString( + eq(AppPreferencesHelper.KEY_LAST_KNOWN_LNG), eq("107.0") + ) + } + + private fun anyString(): String = ArgumentMatchers.anyString() + companion object { private val searchHistory = linkedSetOf("circle K", "bsmart quan 8") } diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/StoreDtoTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/StoreDtoTest.kt new file mode 100644 index 00000000..9a9471eb --- /dev/null +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/data/remote/store/model/StoreDtoTest.kt @@ -0,0 +1,71 @@ +package com.iceteaviet.fastfoodfinder.data.remote.store.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class StoreDtoTest { + + @Test + fun toDomain_mapsAllFields() { + val dto = StoreDto(1, "Circle K", "123 D1", "10.773996", "106.6898035", "0900000001", 2) + + val store = dto.toDomain() + + assertThat(store.id).isEqualTo(1) + assertThat(store.title).isEqualTo("Circle K") + assertThat(store.address).isEqualTo("123 D1") + assertThat(store.lat).isWithin(0.0001).of(10.773996) + assertThat(store.lng).isWithin(0.0001).of(106.6898035) + assertThat(store.tel).isEqualTo("0900000001") + assertThat(store.type).isEqualTo(2) + } + + @Test + fun toDomain_parsesValidNumericStrings() { + val dto = StoreDto(0, "", "", "0.0", "180.0", "", 0) + + val store = dto.toDomain() + + assertThat(store.lat).isWithin(0.001).of(0.0) + assertThat(store.lng).isWithin(0.001).of(180.0) + } + + @Test + fun toDomain_fallsBackToZero_whenLatIsInvalid() { + val dto = StoreDto(0, "", "", "not_a_number", "106.0", "", 0) + + val store = dto.toDomain() + + assertThat(store.lat).isWithin(0.001).of(0.0) + assertThat(store.lng).isWithin(0.001).of(106.0) + } + + @Test + fun toDomain_fallsBackToZero_whenLngIsEmpty() { + val dto = StoreDto(0, "", "", "10.5", "", "", 0) + + val store = dto.toDomain() + + assertThat(store.lat).isWithin(0.001).of(10.5) + assertThat(store.lng).isWithin(0.001).of(0.0) + } + + @Test + fun toDomain_fallsBackToZero_whenBothInvalid() { + val dto = StoreDto(0, "", "", "abc", "xyz", "", 0) + + val store = dto.toDomain() + + assertThat(store.lat).isWithin(0.001).of(0.0) + assertThat(store.lng).isWithin(0.001).of(0.0) + } + + @Test + fun toDomain_defaultsTypeToZero() { + val dto = StoreDto() + + val store = dto.toDomain() + + assertThat(store.type).isEqualTo(0) + } +} diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModelTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModelTest.kt new file mode 100644 index 00000000..f78a70d2 --- /dev/null +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/ui/main/map/MainMapViewModelTest.kt @@ -0,0 +1,276 @@ +package com.iceteaviet.fastfoodfinder.ui.main.map + +import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository +import com.iceteaviet.fastfoodfinder.data.domain.routing.MapsRoutingRepository +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.iceteaviet.fastfoodfinder.ui.main.search.SearchEventBus +import com.iceteaviet.fastfoodfinder.ui.main.search.SearchEventResult +import com.iceteaviet.fastfoodfinder.utils.StoreType +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class MainMapViewModelTest { + + private val storeRepository: StoreRepository = mock() + private val mapsRoutingRepository: MapsRoutingRepository = mock() + private val searchEventBus = SearchEventBus() + private val preferencesRepository: PreferencesRepository = mock() + + private val testDispatcher = UnconfinedTestDispatcher() + + private val testStore = Store( + id = 1, title = "Circle K", address = "123 District 1", + lat = 10.773996, lng = 106.6898035, tel = "0900000001", type = StoreType.TYPE_CIRCLE_K + ) + + private val testStore2 = Store( + id = 2, title = "FamilyMart", address = "456 District 2", + lat = 10.78, lng = 106.70, tel = "0900000002", type = StoreType.TYPE_FAMILY_MART + ) + + private lateinit var viewModel: MainMapViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + viewModel = MainMapViewModel( + storeRepository, mapsRoutingRepository, searchEventBus, preferencesRepository + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun start_emitsSetupMap() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(true) + advanceUntilIdle() + + assertThat(events.any { it is MainMapEvent.SetupMap }).isTrue() + collectJob.cancel() + } + + @Test + fun start_emitsRequestLocationPermission_whenNoPermission() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(false) + advanceUntilIdle() + + assertThat(events.any { it is MainMapEvent.RequestLocationPermission }).isTrue() + collectJob.cancel() + } + + @Test + fun start_emitsSetMyLocationEnabled_whenHasPermission() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(true) + advanceUntilIdle() + + assertThat(events.any { + it is MainMapEvent.SetMyLocationEnabled && it.enabled + }).isTrue() + collectJob.cancel() + } + + @Test + fun onNearByStoreClicked_emitsAnimateAndDialog() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(true) + advanceUntilIdle() + + viewModel.onNearByStoreClicked(testStore) + + assertThat(events.any { + it is MainMapEvent.AnimateMapCamera && it.zoomToDetail + }).isTrue() + assertThat(events.any { it is MainMapEvent.ShowDialogStoreInfo }).isTrue() + collectJob.cancel() + } + + @Test + fun onCameraMoveStarted_emitsClearNearByStores() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(true) + advanceUntilIdle() + + viewModel.onCameraMoveStarted() + + assertThat(events.any { it is MainMapEvent.ClearNearByStores }).isTrue() + collectJob.cancel() + } + + @Test + fun onNavigationButtonClick_emitsInvalidStoreLocation_whenStoreLocationInvalid() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + val invalidStore = testStore.copy(lat = 0.0, lng = 0.0) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(true) + advanceUntilIdle() + + viewModel.onNavigationButtonClick(invalidStore) + + assertThat(events.any { it is MainMapEvent.ShowInvalidStoreLocationWarning }).isTrue() + collectJob.cancel() + } + + @Test + fun onNavigationButtonClick_emitsCannotGetLocation_whenCurrLocationNull() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(true) + advanceUntilIdle() + + viewModel.onNavigationButtonClick(testStore) + + assertThat(events.any { it is MainMapEvent.ShowCannotGetLocationMessage }).isTrue() + collectJob.cancel() + } + + @Test + fun onLocationFailed_fallsBackToPersistedLocation_whenAvailable() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + whenever(preferencesRepository.getLastKnownLocation()).thenReturn(Pair(10.5, 107.0)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(false) + advanceUntilIdle() + + viewModel.onLocationFailed(-1) + + val animateEvent = events.filterIsInstance().firstOrNull() + assertThat(animateEvent).isNotNull() + assertThat(animateEvent!!.location.latitude).isWithin(0.001).of(10.5) + assertThat(animateEvent.location.longitude).isWithin(0.001).of(107.0) + collectJob.cancel() + } + + @Test + fun onLocationFailed_fallsBackToDefault_whenNoPersistedLocation() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + whenever(preferencesRepository.getLastKnownLocation()).thenReturn(null) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(false) + advanceUntilIdle() + + viewModel.onLocationFailed(-1) + + val animateEvent = events.filterIsInstance().firstOrNull() + assertThat(animateEvent).isNotNull() + collectJob.cancel() + } + + @Test + fun start_loadsAllStoresToCluster() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore, testStore2)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(true) + advanceUntilIdle() + + val addEvent = events.filterIsInstance().firstOrNull() + assertThat(addEvent).isNotNull() + assertThat(addEvent!!.stores).hasSize(2) + collectJob.cancel() + } + + @Test + fun searchEvent_quickAction_loadsStoresByType() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + whenever(storeRepository.findStoresByType(StoreType.TYPE_CIRCLE_K)).thenReturn(listOf(testStore)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(true) + advanceUntilIdle() + + searchEventBus.emit(SearchEventResult(SearchEventResult.SEARCH_ACTION_QUICK, StoreType.TYPE_CIRCLE_K)) + advanceUntilIdle() + + val addEvent = events.filterIsInstance().lastOrNull() + assertThat(addEvent).isNotNull() + assertThat(addEvent!!.stores).hasSize(1) + collectJob.cancel() + } + + @Test + fun searchEvent_storeClick_animatesAndShowsDialog() = runTest { + whenever(storeRepository.getAllStores()).thenReturn(listOf(testStore)) + + val events = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiEvent.collect { events.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start(true) + advanceUntilIdle() + + searchEventBus.emit(SearchEventResult(SearchEventResult.SEARCH_ACTION_STORE_CLICK, "q", testStore)) + advanceUntilIdle() + + assertThat(events.any { + it is MainMapEvent.ShowDialogStoreInfo && it.store.id == 1 + }).isTrue() + collectJob.cancel() + } +} diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModelTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModelTest.kt new file mode 100644 index 00000000..fc9755bb --- /dev/null +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModelTest.kt @@ -0,0 +1,158 @@ +package com.iceteaviet.fastfoodfinder.ui.settings + +import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth +import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRefreshService +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingViewModelTest { + + private val clientAuth: ClientAuth = mock() + private val preferencesRepository: PreferencesRepository = mock() + private val storeRefreshService: StoreRefreshService = mock() + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var viewModel: SettingViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + viewModel = SettingViewModel(clientAuth, preferencesRepository, storeRefreshService) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun start_setsVietnameseFromPrefs() { + whenever(preferencesRepository.getIfLanguageIsVietnamese()).thenReturn(true) + whenever(clientAuth.isSignedIn()).thenReturn(true) + + viewModel.start() + + assertThat(viewModel.uiState.value.isVietnamese).isTrue() + } + + @Test + fun start_setsEnglishFromPrefs() { + whenever(preferencesRepository.getIfLanguageIsVietnamese()).thenReturn(false) + whenever(clientAuth.isSignedIn()).thenReturn(true) + + viewModel.start() + + assertThat(viewModel.uiState.value.isVietnamese).isFalse() + } + + @Test + fun start_showsSignOutButton_whenSignedIn() { + whenever(preferencesRepository.getIfLanguageIsVietnamese()).thenReturn(true) + whenever(clientAuth.isSignedIn()).thenReturn(true) + + viewModel.start() + + assertThat(viewModel.uiState.value.showSignOutButton).isTrue() + } + + @Test + fun start_hidesSignOutButton_whenNotSignedIn() { + whenever(preferencesRepository.getIfLanguageIsVietnamese()).thenReturn(true) + whenever(clientAuth.isSignedIn()).thenReturn(false) + + viewModel.start() + + assertThat(viewModel.uiState.value.showSignOutButton).isFalse() + } + + @Test + fun onLanguageChanged_togglesFromVietnameseToEnglish() { + whenever(preferencesRepository.getIfLanguageIsVietnamese()).thenReturn(true) + viewModel.start() + + viewModel.onLanguageChanged() + + assertThat(viewModel.uiState.value.isVietnamese).isFalse() + assertThat((viewModel.uiState.value.event as SettingEvent.LoadLanguage).languageCode).isEqualTo("en") + verify(preferencesRepository).setIfLanguageIsVietnamese(false) + } + + @Test + fun onLanguageChanged_togglesFromEnglishToVietnamese() { + whenever(preferencesRepository.getIfLanguageIsVietnamese()).thenReturn(false) + viewModel.start() + + viewModel.onLanguageChanged() + + assertThat(viewModel.uiState.value.isVietnamese).isTrue() + assertThat((viewModel.uiState.value.event as SettingEvent.LoadLanguage).languageCode).isEqualTo("vi") + verify(preferencesRepository).setIfLanguageIsVietnamese(true) + } + + @Test + fun onSignOutClicked_signsOutAndEmitsOpenLogin() { + viewModel.onSignOutClicked() + + verify(clientAuth).signOut() + assertThat(viewModel.uiState.value.event).isEqualTo(SettingEvent.OpenLogin) + } + + @Test + fun onLoadStoreFromServer_showsSuccessOnSuccess() = runTest { + whenever(storeRefreshService.refreshStoresFromRemote()).thenReturn(listOf( + Store(id = 1, title = "Test", address = "Addr", lat = 10.0, lng = 106.0) + )) + + val states = mutableListOf() + val collectJob = launch { viewModel.uiState.collect { states.add(it) } } + + viewModel.onLoadStoreFromServer() + advanceUntilIdle() + + assertThat(states.any { it.event is SettingEvent.ShowSuccessLoadingToast }).isTrue() + assertThat(states.last().showLoadingProgressIndicator).isFalse() + collectJob.cancel() + } + + @Test + fun onLoadStoreFromServer_showsFailureOnError() = runTest { + whenever(storeRefreshService.refreshStoresFromRemote()).thenThrow(RuntimeException("fail")) + + val states = mutableListOf() + val collectJob = launch { viewModel.uiState.collect { states.add(it) } } + + viewModel.onLoadStoreFromServer() + advanceUntilIdle() + + assertThat(states.any { it.event is SettingEvent.ShowFailedLoadingToast }).isTrue() + assertThat(states.last().showLoadingProgressIndicator).isFalse() + collectJob.cancel() + } + + @Test + fun markEventConsumed_setsIdle() { + viewModel.onSignOutClicked() + assertThat(viewModel.uiState.value.event).isEqualTo(SettingEvent.OpenLogin) + + viewModel.markEventConsumed() + + assertThat(viewModel.uiState.value.event).isEqualTo(SettingEvent.Idle) + } +} diff --git a/app/src/test/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModelTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModelTest.kt new file mode 100644 index 00000000..140f6938 --- /dev/null +++ b/app/src/test/java/com/iceteaviet/fastfoodfinder/ui/splash/SplashViewModelTest.kt @@ -0,0 +1,181 @@ +package com.iceteaviet.fastfoodfinder.ui.splash + +import com.iceteaviet.fastfoodfinder.data.auth.ClientAuth +import com.iceteaviet.fastfoodfinder.data.domain.prefs.PreferencesRepository +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRefreshService +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository +import com.iceteaviet.fastfoodfinder.data.remote.user.model.User +import com.iceteaviet.fastfoodfinder.domain.model.Store +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class SplashViewModelTest { + + private val clientAuth: ClientAuth = mock() + private val userRepository: com.iceteaviet.fastfoodfinder.data.domain.user.UserRepository = mock() + private val storeRepository: StoreRepository = mock() + private val storeRefreshService: StoreRefreshService = mock() + private val preferencesRepository: PreferencesRepository = mock() + + private val testDispatcher = UnconfinedTestDispatcher() + + private val testStores = listOf( + Store(id = 1, title = "Test", address = "Addr", lat = 10.0, lng = 106.0) + ) + + private val testUser = User("uid123", "Test User", "test@test.com", "", emptyList()) + + private lateinit var viewModel: SplashViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + viewModel = SplashViewModel( + clientAuth, userRepository, storeRepository, storeRefreshService, preferencesRepository + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun start_firstTime_setsFlagAndNavigatesToLogin() = runTest { + whenever(preferencesRepository.getAppLaunchFirstTime()).thenReturn(true) + whenever(storeRefreshService.refreshStoresFromRemote()).thenReturn(testStores) + + val states = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiState.collect { states.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start() + advanceUntilIdle() + + verify(preferencesRepository).setAppLaunchFirstTime(false) + assertThat(states.any { it is SplashUiState.NavigateToLogin }).isTrue() + collectJob.cancel() + } + + @Test + fun start_firstTime_showsErrorWhenRefreshFails_thenNavigatesToLogin() = runTest { + whenever(preferencesRepository.getAppLaunchFirstTime()).thenReturn(true) + whenever(storeRefreshService.refreshStoresFromRemote()).thenThrow(RuntimeException("fail")) + + viewModel.start() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf(SplashUiState.NavigateToLogin::class.java) + } + + @Test + fun start_notFirstTime_signedInWithValidUid_navigatesToMain() = runTest { + whenever(preferencesRepository.getAppLaunchFirstTime()).thenReturn(false) + whenever(clientAuth.isSignedIn()).thenReturn(true) + whenever(clientAuth.getCurrentUserUid()).thenReturn("uid123") + whenever(userRepository.getUser("uid123")).thenReturn(testUser) + whenever(storeRepository.getAllStores()).thenReturn(testStores) + + val states = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiState.collect { states.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start() + advanceUntilIdle() + + assertThat(states.any { it is SplashUiState.NavigateToMain }).isTrue() + verify(userRepository).insertOrUpdateUser(testUser) + collectJob.cancel() + } + + @Test + fun start_notFirstTime_notSignedIn_navigatesToLogin() = runTest { + whenever(preferencesRepository.getAppLaunchFirstTime()).thenReturn(false) + whenever(clientAuth.isSignedIn()).thenReturn(false) + whenever(storeRepository.getAllStores()).thenReturn(testStores) + + val states = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiState.collect { states.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start() + advanceUntilIdle() + + assertThat(states.any { it is SplashUiState.NavigateToLogin }).isTrue() + collectJob.cancel() + } + + @Test + fun start_notFirstTime_signedInInvalidUid_navigatesToLogin() = runTest { + whenever(preferencesRepository.getAppLaunchFirstTime()).thenReturn(false) + whenever(clientAuth.isSignedIn()).thenReturn(true) + whenever(clientAuth.getCurrentUserUid()).thenReturn("") + whenever(storeRepository.getAllStores()).thenReturn(testStores) + + val states = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiState.collect { states.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start() + advanceUntilIdle() + + assertThat(states.any { it is SplashUiState.NavigateToLogin }).isTrue() + collectJob.cancel() + } + + @Test + fun start_emptyData_showsGeneralErrorAndNavigatesToLogin() = runTest { + whenever(preferencesRepository.getAppLaunchFirstTime()).thenReturn(true) + whenever(storeRefreshService.refreshStoresFromRemote()).thenReturn(emptyList()) + + viewModel.start() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf(SplashUiState.NavigateToLogin::class.java) + } + + @Test + fun start_nonEmptyDataException_showsGeneralErrorAndNavigatesToLogin() = runTest { + whenever(preferencesRepository.getAppLaunchFirstTime()).thenReturn(true) + whenever(storeRefreshService.refreshStoresFromRemote()).thenThrow(RuntimeException("network")) + + viewModel.start() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf(SplashUiState.NavigateToLogin::class.java) + } + + @Test + fun markEventConsumed_setsIdle() = runTest { + whenever(preferencesRepository.getAppLaunchFirstTime()).thenReturn(false) + whenever(clientAuth.isSignedIn()).thenReturn(false) + whenever(storeRepository.getAllStores()).thenReturn(testStores) + + val states = mutableListOf() + val collectJob = launch(testDispatcher) { viewModel.uiState.collect { states.add(it) } } + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.start() + advanceUntilIdle() + + viewModel.markEventConsumed() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isEqualTo(SplashUiState.Idle) + collectJob.cancel() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 857d3203..e790c84e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,6 +78,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json-version" } kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines-version" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-version" } ## AndroidX appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat-version" } cardview = { module = "androidx.cardview:cardview", version.ref = "cardview-version" } @@ -147,7 +148,8 @@ local-unit-test = [ "truth", "mockito-core", "mockito-kotlin", - "robolectric" + "robolectric", + "kotlinx-coroutines-test" ] android-ui-test = [ "espresso-core",