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/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 c42d345f..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 @@ -6,37 +6,63 @@ 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.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 { @VisibleForTesting - private var cachedStores: List = ArrayList() + internal var cachedStores: List = ArrayList() + + private val cacheMutex = Mutex() override suspend fun getAllStores(): List = withContext(Dispatchers.IO) { - if (cachedStores.isNotEmpty()) { - return@withContext ArrayList(cachedStores) + 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 localStores = storeDao.getAllStores().map { it.toDomain() } - if (localStores.isNotEmpty()) { - cachedStores = localStores - return@withContext ArrayList(localStores) + override suspend fun refreshStores(): List = withContext(Dispatchers.IO) { + cacheMutex.withLock { + val remoteStores = storeApiHelper.getAllStores() + val filtered = filterInvalidData(remoteStores) + persistStoresLocked(filtered) + ArrayList(filtered) } - - val remoteStores = storeApiHelper.getAllStores() - cachedStores = remoteStores - ArrayList(remoteStores) } override suspend fun setStores(storeList: List) = withContext(Dispatchers.IO) { - cachedStores = storeList - storeDao.insertAll(storeList.map { it.toEntity() }) + cacheMutex.withLock { + persistStoresLocked(storeList) + } + } + + private suspend fun persistStoresLocked(stores: List) { + cachedStores = stores + if (stores.isNotEmpty()) { + storeDao.insertAll(stores.map { it.toEntity() }) + } else { + storeDao.deleteAll() + } } override suspend fun getStoreInBounds(lat: Double, lng: Double, radius: Double): List = withContext(Dispatchers.IO) { @@ -57,22 +83,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() } } @@ -86,8 +96,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) { @@ -98,7 +110,16 @@ class AppStoreRepository(private val storeApiHelper: StoreApiHelper, private val storeApiHelper.insertOrUpdateComment(storeId, comment) } - fun clearCache() { - cachedStores = ArrayList() + suspend fun clearCache() { + cacheMutex.withLock { + cachedStores = ArrayList() + } + } + + @VisibleForTesting + internal fun filterInvalidData(stores: List): List { + return stores.filter { store -> + 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/StoreRefreshService.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/data/domain/store/StoreRefreshService.kt new file mode 100644 index 00000000..58c7dc85 --- /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() + } + + clientAuth.signInWithEmailAndPassword(botEmail, botPassword) + try { + return storeRepository.refreshStores() + } finally { + clientAuth.signOut() + } + } +} 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..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,12 +1,14 @@ 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 { 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/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/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/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..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 @@ -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 @@ -14,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)) } @@ -22,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()) } @@ -34,25 +39,26 @@ 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) { 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/service/workers/SyncDatabaseWorker.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/service/workers/SyncDatabaseWorker.kt index 7dec9bbd..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,22 +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.utils.filterInvalidData +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 { @@ -33,13 +28,11 @@ class SyncDatabaseWorker @AssistedInject constructor( ) return try { - val storeList = StoreSyncHelper.loadStoresFromServer(applicationContext, clientAuth, storeRepository) - val filteredStoreList = filterInvalidData(storeList.toMutableList()) - storeRepository.setStores(filteredStoreList) + val storeList = storeRefreshService.refreshStoresFromRemote() - 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/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..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,15 +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.data.remote.store.model.Store +import com.iceteaviet.fastfoodfinder.domain.model.Store 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 @@ -39,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 @@ -57,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) @@ -73,7 +71,7 @@ class MainMapFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupUI() + setupUI(savedInstanceState) mMapFragment = inflateSupportMapFragment() setupObservers() } @@ -86,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() } } } @@ -189,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)) } @@ -209,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.getPosition())) continue - - val marker = googleMap!!.addMarker( - MarkerOptions().position(store.getPosition()) - .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) } @@ -278,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) @@ -307,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() } @@ -322,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 { @@ -360,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 a72c0cac..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,11 +8,13 @@ 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 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 @@ -24,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 @@ -36,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() @@ -44,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) @@ -60,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 @@ -69,18 +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() @@ -90,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) } @@ -117,7 +107,7 @@ class MainMapViewModel @Inject constructor( resetState() if (!hasLocationPermission) { - _uiState.value = MainMapEvent.RequestLocationPermission + _uiEvent.tryEmit(MainMapEvent.RequestLocationPermission) } else { onLocationPermissionGranted() } @@ -129,12 +119,19 @@ class MainMapViewModel @Inject constructor( isBusRegistered = true } - _uiState.value = MainMapEvent.SetupMap + _uiEvent.tryEmit(MainMapEvent.SetupMap) - loadAllStoresToMap() + if (!isStoresLoaded) { + loadAllStoresToMap() + } + + if (!isZoomToUser) { + startLocationTimeout() + } } override fun onCleared() { + locationTimeoutJob?.cancel() if (::locationManager.isInitialized) { unsubscribeLocationUpdate() } @@ -146,7 +143,7 @@ class MainMapViewModel @Inject constructor( subscribeLocationUpdate() requestCurrentLocation() } - _uiState.value = MainMapEvent.SetMyLocationEnabled(true) + _uiEvent.tryEmit(MainMapEvent.SetMyLocationEnabled(true)) locationGranted = true } @@ -163,36 +160,42 @@ 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) { - val storeLocation = store.getPosition() + val storeLocation = store.toLatLng() 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 } @@ -206,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) { @@ -237,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() { @@ -256,19 +259,42 @@ 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) { val store = storeList[i] - if (bounds.contains(store.getPosition())) { + if (bounds.contains(store.toLatLng())) { stores.add(store) } } @@ -276,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.getPosition()))) - } - return res + return stores.map { store -> + NearByStore(store, distanceBetween(currPos, store.toLatLng())) + }.sortedBy { it.distance } } private fun handleSearchQuickAction(storeType: Int) { @@ -288,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].getPosition(), 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)) } } } @@ -305,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].getPosition(), 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)) } } } @@ -320,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)) } } @@ -329,26 +353,27 @@ 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) + _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.getPosition(), 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 9d9dc2ff..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 @@ -10,44 +10,27 @@ 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 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 a9a97b40..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 @@ -1,22 +1,5 @@ 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. - */ -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/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/settings/SettingViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/settings/SettingViewModel.kt index a00f7db1..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,12 +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.filterInvalidData -import com.iceteaviet.fastfoodfinder.utils.loadStoresFromServerHelper +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRefreshService import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -34,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()) @@ -56,26 +53,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) - + storeRefreshService.refreshStoresFromRemote() + _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..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,15 +2,13 @@ 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.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 @@ -32,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() { @@ -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 = storeRefreshService.refreshStoresFromRemote() + if (storeList.isEmpty()) { _uiState.value = SplashUiState.ShowRetryDialog throw EmptyDataException() } 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 af7bd820..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 @@ -1,12 +1,14 @@ package com.iceteaviet.fastfoodfinder.ui.storelist import androidx.lifecycle.ViewModel -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store -import com.iceteaviet.fastfoodfinder.utils.getFakeStoreList +import androidx.lifecycle.viewModelScope +import com.iceteaviet.fastfoodfinder.data.domain.store.StoreRepository +import com.iceteaviet.fastfoodfinder.domain.model.Store 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..74b7b711 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.data.remote.store.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,20 @@ 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)) - 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)) - 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(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(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 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 +110,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 +204,7 @@ fun getFakeMapsDirection(): MapsDirection { return Gson().fromJson(directionData, MapsDirection::class.java) } +@Suppress("unused") fun getFakeEmptyLegMapsDirection(): MapsDirection { val directionData = "{\n" + " \"routes\": [\n" + @@ -270,6 +220,7 @@ fun getFakeEmptyLegMapsDirection(): MapsDirection { return Gson().fromJson(directionData, MapsDirection::class.java) } +@Suppress("unused") fun getFakeEmptyStepMapsDirection(): MapsDirection { val directionData = "{\n" + " \"routes\": [\n" + @@ -319,18 +270,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 { @@ -345,25 +292,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" } @@ -374,7 +302,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 } @@ -383,8 +310,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))) } @@ -392,12 +319,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 -> "" } } 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 deleted file mode 100644 index 6949923f..00000000 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/utils/StoreSyncHelper.kt +++ /dev/null @@ -1,22 +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.data.remote.store.model.Store - -object StoreSyncHelper { - suspend fun loadStoresFromServer(context: Context, clientAuth: ClientAuth, storeRepository: StoreRepository): List { - if (clientAuth.isSignedIn()) { - return storeRepository.getAllStores() - } else { - try { - clientAuth.signInWithEmailAndPassword(context.getString(R.string.downloader_bot_email), context.getString(R.string.downloader_bot_pwd)) - return storeRepository.getAllStores() - } finally { - clientAuth.signOut() - } - } - } -} 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 5091ca06..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 @@ -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 @@ -34,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 } @@ -60,23 +56,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() 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" /> 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/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/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 5d6a4ae1..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 @@ -1,33 +1,69 @@ 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.domain.model.Store +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, + "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 = 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) { + 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..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,16 +7,19 @@ 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 -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 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 @@ -31,10 +34,14 @@ object RepositoryModule { @Provides @Singleton - fun provideStoreRepository(): StoreRepository { - val remote = FakeFirebaseStoreApiHelper() - val local = FakeStoreDAO() - return AppStoreRepository(remote, local) + fun provideStoreApiHelper(@ApplicationContext context: Context): StoreApiHelper { + return FakeFirebaseStoreApiHelper(context) + } + + @Provides + @Singleton + fun provideStoreRepository(storeApiHelper: StoreApiHelper, storeDao: StoreDao): StoreRepository { + return AppStoreRepository(storeApiHelper, storeDao) } @Provides @@ -60,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 45092960..175b8bd7 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 @@ -10,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 @@ -20,6 +22,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 +37,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 @@ -61,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 { 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..db76fd1c --- /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.domain.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.0 + private const val STORE_INVALID_LNG = -1.0 + } +} 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/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt b/app/src/test/java/com/iceteaviet/fastfoodfinder/utils/DataUtilsTest.kt index ffb3e215..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 { @@ -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" 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) 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 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",