backup, ongoing: api/cache/sync

This commit is contained in:
Emin 2024-07-08 15:51:31 +05:00
parent b7eeeb2ed7
commit bba8edbf48
77 changed files with 1257 additions and 479 deletions

View file

@ -10,16 +10,26 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import app.organicmaps.R
import app.tourism.data.repositories.PlacesRepository
import app.tourism.ui.screens.auth.AuthNavigation
import app.tourism.ui.theme.OrganicMapsTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class AuthActivity : ComponentActivity() {
@Inject
lateinit var placesRepository: PlacesRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
placesRepository.downloadAllDataIfFirstTime()
}
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(resources.getColor(R.color.black_primary)),
navigationBarStyle = SystemBarStyle.dark(resources.getColor(R.color.black_primary))

View file

@ -0,0 +1,13 @@
package app.tourism.data.db
import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class Converters {
@TypeConverter
fun fromList(value : List<String>) = Json.encodeToString(value)
@TypeConverter
fun toList(value: String) = Json.decodeFromString<List<String>>(value)
}

View file

@ -0,0 +1,26 @@
package app.tourism.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import app.tourism.data.db.dao.CurrencyRatesDao
import app.tourism.data.db.dao.HashesDao
import app.tourism.data.db.dao.PlacesDao
import app.tourism.data.db.dao.ReviewsDao
import app.tourism.data.db.entities.CurrencyRatesEntity
import app.tourism.data.db.entities.HashEntity
import app.tourism.data.db.entities.PlaceEntity
import app.tourism.data.db.entities.ReviewEntity
@Database(
entities = [PlaceEntity::class, ReviewEntity::class, HashEntity::class, CurrencyRatesEntity::class],
version = 2,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class Database : RoomDatabase() {
abstract val currencyRatesDao: CurrencyRatesDao
abstract val placesDao: PlacesDao
abstract val hashesDao: HashesDao
abstract val reviewsDao: ReviewsDao
}

View file

@ -1,16 +1,16 @@
package app.tourism.db.dao
package app.tourism.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.tourism.db.entities.CurrencyRatesEntity
import app.tourism.data.db.entities.CurrencyRatesEntity
@Dao
interface CurrencyRatesDao {
@Query("SELECT * FROM currency_rates")
fun getCurrencyRates(): CurrencyRatesEntity?
suspend fun getCurrencyRates(): CurrencyRatesEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateCurrencyRates(entity: CurrencyRatesEntity)

View file

@ -0,0 +1,22 @@
package app.tourism.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.tourism.data.db.entities.HashEntity
@Dao
interface HashesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHash(hash: HashEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHashes(hashes: List<HashEntity>)
@Query("SELECT * FROM hashes WHERE categoryId = :id")
suspend fun getHash(id: Long): HashEntity
@Query("SELECT * FROM hashes")
suspend fun getHashes(): List<HashEntity>
}

View file

@ -0,0 +1,39 @@
package app.tourism.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.tourism.data.db.entities.PlaceEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PlacesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPlaces(places: List<PlaceEntity>)
@Query("DELETE FROM places")
suspend fun deleteAllPlaces()
@Query("DELETE FROM places WHERE categoryId = :categoryId")
suspend fun deleteAllPlacesByCategory(categoryId: Long)
@Query("SELECT * FROM places WHERE categoryId = :categoryId")
fun getPlacesByCategoryId(categoryId: Long): Flow<List<PlaceEntity>>
@Query("SELECT * FROM places WHERE categoryId =:categoryId ORDER BY rating DESC LIMIT 15")
fun getTopPlacesByCategoryId(categoryId: Long): Flow<List<PlaceEntity>>
@Query("SELECT * FROM places WHERE id = :placeId")
fun getPlaceById(placeId: Long): Flow<PlaceEntity>
@Query("SELECT * FROM places WHERE isFavorite = 1 AND UPPER(name) LIKE UPPER(:q)")
fun getFavoritePlaces(q: String = ""): Flow<List<PlaceEntity>>
@Query("UPDATE places SET isFavorite = :isFavorite WHERE id = :placeId")
suspend fun setFavorite(placeId: Long, isFavorite: Boolean)
@Query("SELECT * FROM places WHERE UPPER(name) LIKE UPPER(:q)")
fun search(q: String= ""): Flow<List<PlaceEntity>>
}

View file

@ -0,0 +1,31 @@
package app.tourism.data.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.tourism.data.db.entities.ReviewEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ReviewsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertReview(review: ReviewEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertReviews(review: List<ReviewEntity>)
@Delete
suspend fun deleteReview(review: ReviewEntity)
@Query("DELETE FROM reviews")
suspend fun deleteAllReviews()
@Query("DELETE FROM reviews WHERE placeId = :placeId")
suspend fun deleteAllPlaceReviews(placeId: Long)
@Query("SELECT * FROM reviews WHERE placeId = :placeId")
fun getReviewsForPlace(placeId: Long): Flow<List<ReviewEntity>>
}

View file

@ -1,4 +1,4 @@
package app.tourism.db.entities
package app.tourism.data.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View file

@ -0,0 +1,10 @@
package app.tourism.data.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "hashes")
data class HashEntity(
@PrimaryKey val categoryId: Long,
val value: String,
)

View file

@ -0,0 +1,50 @@
package app.tourism.data.db.entities
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import app.tourism.data.dto.PlaceLocation
import app.tourism.domain.models.common.PlaceShort
import app.tourism.domain.models.details.PlaceFull
@Entity(tableName = "places")
data class PlaceEntity(
@PrimaryKey val id: Long,
val categoryId: Long,
val name: String,
val excerpt: String,
val description: String,
val cover: String,
val gallery: List<String>,
@Embedded val coordinates: CoordinatesEntity,
val rating: Double,
val isFavorite: Boolean
) {
fun toPlaceFull() = PlaceFull(
id = id,
name = name,
rating = rating,
excerpt = excerpt,
description = description,
placeLocation = coordinates.toPlaceLocation(name),
cover = cover,
pics = gallery,
isFavorite = isFavorite
)
fun toPlaceShort() = PlaceShort(
id = id,
name = name,
cover = cover,
rating = rating,
excerpt = excerpt,
isFavorite = isFavorite
)
}
data class CoordinatesEntity(
val latitude: Double,
val longitude: Double
) {
fun toPlaceLocation(name: String) = PlaceLocation(name, latitude, longitude)
}

View file

@ -0,0 +1,27 @@
package app.tourism.data.db.entities
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import app.tourism.domain.models.details.Review
@Entity(tableName = "reviews")
data class ReviewEntity(
@PrimaryKey val id: Long,
val placeId: Long,
@Embedded val user: JustUser,
val comment: String,
val date: String,
val rating: Int,
val images: List<String>
) {
fun toReview() = Review(
id = id,
placeId = placeId,
rating = rating,
user = user.toUser(),
date = date,
comment = comment,
picsUrls = images,
)
}

View file

@ -0,0 +1,12 @@
package app.tourism.data.db.entities
import app.tourism.domain.models.details.User
data class JustUser(
val userId: Long,
val fullName: String,
val avatar: String?,
val country: String
) {
fun toUser() = User(id = userId, name = fullName, pfpUrl = avatar, countryCodeName = country)
}

View file

@ -0,0 +1,13 @@
package app.tourism.data.dto
import app.tourism.data.dto.place.PlaceDto
data class AllDataDto(
val attractions: List<PlaceDto>,
val restaurants: List<PlaceDto>,
val accommodations: List<PlaceDto>,
val attractions_hash: String,
val restaurants_hash: String,
val accommodations_hash: String,
)

View file

@ -0,0 +1,8 @@
package app.tourism.data.dto
import app.tourism.data.dto.place.PlaceDto
data class CategoryDto(
val data: List<PlaceDto>,
val hash: String
)

View file

@ -0,0 +1,7 @@
package app.tourism.data.dto
import app.tourism.data.dto.place.PlaceDto
data class FavoritesDto(
val data: List<PlaceDto>,
)

View file

@ -3,6 +3,7 @@ package app.tourism.data.dto
import android.os.Parcelable
import app.organicmaps.bookmarks.data.FeatureId
import app.organicmaps.bookmarks.data.MapObject
import app.tourism.data.db.entities.CoordinatesEntity
import kotlinx.parcelize.Parcelize
@Parcelize
@ -10,4 +11,6 @@ data class PlaceLocation(val name: String, val lat: Double, val lon: Double) : P
fun toMapObject() = MapObject.createMapObject(
FeatureId.EMPTY, MapObject.POI, name, "", lat, lon
);
fun toCoordinatesEntity() = CoordinatesEntity(lat, lon)
}

View file

@ -0,0 +1,15 @@
package app.tourism.data.dto.place
import app.tourism.data.dto.PlaceLocation
data class CoordinatesDto(
val latitude: String,
val longitude: String
) {
fun toPlaceLocation(name: String) =
PlaceLocation(
name,
latitude.toDouble(),
longitude.toDouble()
)
}

View file

@ -0,0 +1,29 @@
package app.tourism.data.dto.place
import app.tourism.domain.models.common.PlaceShort
import app.tourism.domain.models.details.PlaceFull
data class PlaceDto(
val id: Long,
val name: String,
val coordinates: CoordinatesDto,
val cover: String,
val feedbacks: List<ReviewDto> = emptyList(),
val gallery: List<String>,
val rating: String,
val short_description: String,
val long_description: String,
) {
fun toPlaceFull(isFavorite: Boolean) = PlaceFull(
id = id,
name = name,
rating = rating.toDouble(),
excerpt = short_description,
description = long_description,
placeLocation = coordinates.toPlaceLocation(name),
cover = cover,
pics = gallery,
isFavorite = isFavorite,
reviews = feedbacks.map { it.toReview() }
)
}

View file

@ -0,0 +1,23 @@
package app.tourism.data.dto.place
import app.tourism.domain.models.details.Review
data class ReviewDto(
val id: Long,
val mark_id: Long,
val images: List<String>,
val message: String,
val points: Int,
val created_at: String,
val user: UserDto
) {
fun toReview() = Review(
id = id,
placeId = mark_id,
rating = points,
user = user.toUser(),
date = created_at,
comment = message,
picsUrls = images
)
}

View file

@ -0,0 +1,18 @@
package app.tourism.data.dto.place
import app.tourism.domain.models.details.User
data class UserDto(
val id: Long,
val avatar: String,
val country: String,
val full_name: String,
) {
fun toUser() = User(
id = id,
name = full_name,
countryCodeName = country,
pfpUrl = avatar,
)
}

View file

@ -0,0 +1,3 @@
package app.tourism.data.dto.profile
data class LanguageDto(val language: String)

View file

@ -0,0 +1,3 @@
package app.tourism.data.dto.profile
data class ThemeDto(val theme: String)

View file

@ -11,28 +11,49 @@ import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
suspend inline fun <T, reified R> FlowCollector<Resource<R>>.handleCall(
suspend inline fun <T, reified R> FlowCollector<Resource<R>>.handleGenericCall(
call: () -> Response<T>,
mapper: (T) -> R,
emitLoadingStatusBeforeCall: Boolean = true
) {
if(emitLoadingStatusBeforeCall) emit(Resource.Loading())
if (emitLoadingStatusBeforeCall) emit(Resource.Loading())
try {
val response = call()
val body = response.body()?.let { mapper(it) }
if (response.isSuccessful) emit(Resource.Success(body))
else emit(response.parseError())
} catch(e: HttpException) {
} catch (e: HttpException) {
emit(
Resource.Error(
message = "Упс! Что-то пошло не так."
))
} catch(e: IOException) {
message = "Упс! Что-то пошло не так."
)
)
} catch (e: IOException) {
emit(
Resource.Error(
message = "Не удается соединиться с сервером, проверьте интернет подключение"
)
)
} catch (e: Exception) {
emit(Resource.Error(message = "Упс! Что-то пошло не так."))
}
}
suspend inline fun <reified T> handleResponse(call: () -> Response<T>): Resource<T> {
try {
val response = call()
if (response.isSuccessful) {
val body = response.body()!!
return Resource.Success(body)
} else return response.parseError()
} catch (e: HttpException) {
return Resource.Error(message = "Упс! Что-то пошло не так.")
} catch (e: IOException) {
return Resource.Error(
message = "Не удается соединиться с сервером, проверьте интернет подключение"
))
)
} catch (e: Exception) {
return Resource.Error(message = "Упс! Что-то пошло не так.")
}
}

View file

@ -1,17 +1,27 @@
package app.tourism.data.remote
import app.tourism.data.dto.AllDataDto
import app.tourism.data.dto.CategoryDto
import app.tourism.data.dto.FavoritesDto
import app.tourism.data.dto.auth.AuthResponseDto
import app.tourism.data.dto.place.ReviewDto
import app.tourism.data.dto.profile.LanguageDto
import app.tourism.data.dto.profile.ThemeDto
import app.tourism.data.dto.profile.UserData
import app.tourism.domain.models.SimpleResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
interface TourismApi {
// region auth
@ -52,6 +62,50 @@ interface TourismApi {
@Part("_method") _method: RequestBody? = "PUT".toFormDataRequestBody(),
@Part avatar: MultipartBody.Part? = null
): Response<UserData>
@PUT("profile/lang")
suspend fun updateLanguage(@Body language: LanguageDto): Response<UserData>
@PUT("profile/theme")
suspend fun updateTheme(@Body theme: ThemeDto): Response<UserData>
// endregion profile
// region places
@GET("marks/{id}")
suspend fun getPlacesByCategory(@Path("id") id: Long): Response<CategoryDto>
@GET("marks/all")
suspend fun getAllPlaces(): Response<AllDataDto>
// endregion places
// region favorites
@GET("favourite-marks")
suspend fun getFavorites(): Response<FavoritesDto>
@POST("favourite-marks")
suspend fun addFavorites(@Body ids: List<Long>): Response<SimpleResponse>
@DELETE("favourite-marks")
suspend fun removeFromFavorites(@Body ids: List<Long>): Response<SimpleResponse>
// endregion favorites
// region reviews
@GET("feedbacks/{id}")
suspend fun getReviewsByPlaceId(id: Long): Response<List<ReviewDto>>
@Multipart
@POST("feedbacks")
suspend fun postReview(
@Part("message") comment: RequestBody? = null,
@Part("mark_id") placeId: RequestBody? = null,
@Part("points") points: RequestBody? = null,
@Part images: List<MultipartBody.Part>? = null
): Response<SimpleResponse>
@DELETE("feedbacks/{mark_id}")
suspend fun deleteReview(
@Path("mark_id") placeId: Long,
@Body reviewsIds: List<Long>,
): Response<SimpleResponse>
// endregion reviews
}

View file

@ -1,7 +1,7 @@
package app.tourism.data.repositories
import app.tourism.data.remote.TourismApi
import app.tourism.data.remote.handleCall
import app.tourism.data.remote.handleGenericCall
import app.tourism.domain.models.SimpleResponse
import app.tourism.domain.models.auth.AuthResponse
import app.tourism.domain.models.auth.RegistrationData
@ -11,14 +11,14 @@ import kotlinx.coroutines.flow.flow
class AuthRepository(private val api: TourismApi) {
fun signIn(email: String, password: String): Flow<Resource<AuthResponse>> = flow {
handleCall(
handleGenericCall(
call = { api.signIn(email, password) },
mapper = { it.toAuthResponse() }
)
}
fun signUp(registrationData: RegistrationData) = flow {
handleCall(
handleGenericCall(
call = {
api.signUp(
registrationData.fullName,
@ -33,7 +33,7 @@ class AuthRepository(private val api: TourismApi) {
}
fun signOut(): Flow<Resource<SimpleResponse>> = flow {
handleCall(
handleGenericCall(
call = { api.signOut() },
mapper = { it }
)

View file

@ -1,17 +1,13 @@
package app.tourism.data.repositories
import app.tourism.data.db.Database
import app.tourism.data.dto.currency.CurrenciesList
import app.tourism.data.remote.CurrencyApi
import app.tourism.data.remote.handleCall
import app.tourism.db.Database
import app.tourism.data.remote.handleGenericCall
import app.tourism.domain.models.profile.CurrencyRates
import app.tourism.domain.models.resource.Resource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.Double.Companion.NaN
class CurrencyRepository(private val api: CurrencyApi, private val db: Database) {
@ -22,7 +18,7 @@ class CurrencyRepository(private val api: CurrencyApi, private val db: Database)
emit(Resource.Success(it.toCurrencyRates()))
}
handleCall(
handleGenericCall(
call = { api.getCurrency() },
mapper = {
val currencyRates = getCurrencyRatesFromXml(it)

View file

@ -0,0 +1,174 @@
package app.tourism.data.repositories
import android.content.Context
import app.organicmaps.R
import app.tourism.data.db.Database
import app.tourism.data.db.entities.HashEntity
import app.tourism.data.db.entities.PlaceEntity
import app.tourism.data.db.entities.ReviewEntity
import app.tourism.data.dto.place.PlaceDto
import app.tourism.data.remote.TourismApi
import app.tourism.data.remote.handleGenericCall
import app.tourism.data.remote.handleResponse
import app.tourism.domain.models.SimpleResponse
import app.tourism.domain.models.categories.PlaceCategory
import app.tourism.domain.models.common.PlaceShort
import app.tourism.domain.models.details.PlaceFull
import app.tourism.domain.models.details.Review
import app.tourism.domain.models.resource.Resource
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flow
class PlacesRepository(
private val api: TourismApi,
private val db: Database,
@ApplicationContext private val context: Context,
) {
private val placesDao = db.placesDao
private val reviewsDao = db.reviewsDao
private val hashesDao = db.hashesDao
fun downloadAllDataIfFirstTime(): Flow<Resource<SimpleResponse>> = flow {
val hashes = hashesDao.getHashes()
val favoritesResponse = handleResponse { api.getFavorites() }
if (hashes.isEmpty()) {
handleGenericCall(
call = { api.getAllPlaces() },
mapper = { data ->
// get data
val favorites =
if (favoritesResponse is Resource.Success) favoritesResponse.data?.data?.map {
it.toPlaceFull(true)
} else null
val reviews = mutableListOf<Review>()
fun PlaceDto.toEntity(placeCategory: PlaceCategory): PlaceEntity {
var placeFull = this.toPlaceFull(false)
placeFull =
placeFull.copy(
isFavorite = favorites?.any { it.id == placeFull.id } ?: false
)
placeFull.reviews?.let { it1 -> reviews.addAll(it1) }
return placeFull.toPlaceEntity(placeCategory.id)
}
val sightsEntities = data.attractions.map { placeDto ->
placeDto.toEntity(PlaceCategory.Sights)
}
val restaurantsEntities = data.restaurants.map { placeDto ->
placeDto.toEntity(PlaceCategory.Restaurants)
}
val hotelsEntities = data.accommodations.map { placeDto ->
placeDto.toEntity(PlaceCategory.Hotels)
}
// update places
placesDao.deleteAllPlaces()
placesDao.insertPlaces(sightsEntities)
placesDao.insertPlaces(restaurantsEntities)
placesDao.insertPlaces(hotelsEntities)
// update reviews
val reviewsEntities = reviews.map { it.toReviewEntity() }
reviewsDao.deleteAllReviews()
reviewsDao.insertReviews(reviewsEntities)
// update hashes
hashesDao.insertHashes(
listOf(
HashEntity(PlaceCategory.Sights.id, data.attractions_hash),
HashEntity(PlaceCategory.Restaurants.id, data.restaurants_hash),
HashEntity(PlaceCategory.Hotels.id, data.accommodations_hash),
)
)
// return response
SimpleResponse(message = context.getString(R.string.great_success))
}
)
}
}
fun search(q: String): Flow<Resource<List<PlaceShort>>> = channelFlow {
placesDao.search("%$q%").collectLatest { placeEntities ->
val places = placeEntities.map { it.toPlaceShort() }
send(Resource.Success(places))
}
}
fun getPlacesByCategory(id: Long): Flow<Resource<List<PlaceShort>>> = channelFlow {
val hash = hashesDao.getHash(id)
if (hash.value.isNotBlank()) {
placesDao.getPlacesByCategoryId(categoryId = id)
.collectLatest { placeEntities ->
send(Resource.Success(placeEntities.map { it.toPlaceShort() }))
}
}
var favorites = listOf<PlaceEntity>()
placesDao.getFavoritePlaces("").collectLatest {
favorites = it
}
val resource = handleResponse { api.getPlacesByCategory(id) }
if (resource is Resource.Success) {
resource.data?.let { categoryDto ->
if (hash.value != categoryDto.hash) {
// update places
hashesDao.insertHash(hash.copy(value = categoryDto.hash))
placesDao.deleteAllPlacesByCategory(categoryId = id)
val places = categoryDto.data.map { placeDto ->
var placeFull = placeDto.toPlaceFull(false)
placeFull =
placeFull.copy(isFavorite = favorites.any { it.id == placeFull.id })
placeFull
}
placesDao.insertPlaces(places.map { it.toPlaceEntity(id) })
// update reviews
val reviewsEntities = mutableListOf<ReviewEntity>()
places.forEach { place ->
place.reviews?.map { review -> review.toReviewEntity() }
?.also { reviewEntity -> reviewsEntities.addAll(reviewEntity) }
}
reviewsDao.deleteAllReviews()
reviewsDao.insertReviews(reviewsEntities)
}
}
}
}
fun getTopPlaces(id: Long): Flow<Resource<List<PlaceShort>>> = channelFlow {
placesDao.getTopPlacesByCategoryId(categoryId = id)
.collectLatest { placeEntities ->
send(Resource.Success(placeEntities.map { it.toPlaceShort() }))
}
}
fun getPlaceById(id: Long): Flow<Resource<PlaceFull>> = channelFlow {
placesDao.getPlaceById(id)
.collectLatest { placeEntity ->
send(Resource.Success(placeEntity.toPlaceFull()))
}
}
fun getFavorites(q: String): Flow<Resource<List<PlaceShort>>> = channelFlow {
placesDao.getFavoritePlaces("%$q%")
.collectLatest { placeEntities ->
send(Resource.Success(placeEntities.map { it.toPlaceShort() }))
}
}
suspend fun setFavorite(placeId: Long, isFavorite: Boolean) {
placesDao.setFavorite(placeId, isFavorite)
}
}

View file

@ -1,9 +1,11 @@
package app.tourism.data.repositories
import android.content.Context
import app.tourism.data.dto.profile.LanguageDto
import app.tourism.data.dto.profile.ThemeDto
import app.tourism.data.prefs.UserPreferences
import app.tourism.data.remote.TourismApi
import app.tourism.data.remote.handleCall
import app.tourism.data.remote.handleGenericCall
import app.tourism.data.remote.toFormDataRequestBody
import app.tourism.domain.models.profile.PersonalData
import app.tourism.domain.models.resource.Resource
@ -21,7 +23,7 @@ class ProfileRepository(
@ApplicationContext private val context: Context
) {
fun getPersonalData(): Flow<Resource<PersonalData>> = flow {
handleCall(
handleGenericCall(
call = { api.getUser() },
mapper = {
it.data.toPersonalData()
@ -46,7 +48,7 @@ class ProfileRepository(
val language = userPreferences.getLanguage()?.code
val theme = userPreferences.getTheme()?.code
handleCall(
handleGenericCall(
call = {
api.updateProfile(
fullName = fullName.toFormDataRequestBody(),
@ -63,7 +65,7 @@ class ProfileRepository(
suspend fun updateLanguage(code: String) {
try {
api.updateProfile(language = code.toFormDataRequestBody())
api.updateLanguage(language = LanguageDto(code))
} catch (e: Exception) {
println(e.message)
}
@ -71,7 +73,7 @@ class ProfileRepository(
suspend fun updateTheme(code: String) {
try {
api.updateProfile(theme = code.toFormDataRequestBody())
api.updateTheme(theme = ThemeDto(code))
} catch (e: Exception) {
println(e.message)
}

View file

@ -0,0 +1,77 @@
package app.tourism.data.repositories
import app.tourism.data.db.Database
import app.tourism.data.remote.TourismApi
import app.tourism.data.remote.handleResponse
import app.tourism.data.remote.toFormDataRequestBody
import app.tourism.domain.models.SimpleResponse
import app.tourism.domain.models.details.Review
import app.tourism.domain.models.details.ReviewToPost
import app.tourism.domain.models.resource.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
class ReviewsRepository(
private val api: TourismApi,
private val db: Database,
) {
private val reviewsDao = db.reviewsDao
fun getReviewsForPlace(id: Long): Flow<Resource<List<Review>>> = channelFlow {
reviewsDao.getReviewsForPlace(id).collectLatest { reviewsEntities ->
val reviews = reviewsEntities.map { it.toReview() }
send(Resource.Success(reviews))
}
}
fun postReview(review: ReviewToPost): Flow<Resource<SimpleResponse>> = flow {
val imageMultiparts = mutableListOf<MultipartBody.Part>()
review.images.forEach {
val requestBody = it.asRequestBody("image/*".toMediaType())
val imageMultipart =
MultipartBody.Part.createFormData("images[]", it.name, requestBody)
imageMultiparts.add(imageMultipart)
}
emit(Resource.Loading())
val postReviewResponse = handleResponse {
api.postReview(
placeId = review.placeId.toString().toFormDataRequestBody(),
comment = review.comment.toFormDataRequestBody(),
points = review.rating.toString().toFormDataRequestBody(),
images = imageMultiparts
)
}
emit(postReviewResponse)
if (postReviewResponse is Resource.Success) {
updateReviewsForDb(review.placeId)
}
}
fun deleteReviews(placeId: Long, reviewsIds: List<Long>): Flow<Resource<SimpleResponse>> =
flow {
val deleteReviewsResponse = handleResponse {
api.deleteReview(placeId, reviewsIds)
}
emit(deleteReviewsResponse)
if (deleteReviewsResponse is Resource.Success) {
updateReviewsForDb(placeId)
}
}
private suspend fun updateReviewsForDb(id: Long) {
val getReviewsResponse = handleResponse {
api.getReviewsByPlaceId(id)
}
if (getReviewsResponse is Resource.Success) {
reviewsDao.deleteAllPlaceReviews(id)
}
}
}

View file

@ -1,16 +0,0 @@
package app.tourism.db
import androidx.room.Database
import androidx.room.RoomDatabase
import app.tourism.db.dao.CurrencyRatesDao
import app.tourism.db.entities.CurrencyRatesEntity
@Database(
entities = [CurrencyRatesEntity::class],
version = 1,
exportSchema = false
)
abstract class Database: RoomDatabase() {
abstract val currencyRatesDao: CurrencyRatesDao
}

View file

@ -1,21 +0,0 @@
package app.tourism.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.tourism.db.entities.Feedback
@Dao
interface FeedbackDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertFeedback(feedback: Feedback)
@Delete
suspend fun deleteFeedback(feedback: Feedback)
@Query("SELECT * FROM feedbacks WHERE placeId = :placeId")
suspend fun getFeedbacksForPlace(placeId: Long): List<Feedback>
}

View file

@ -1,31 +0,0 @@
package app.tourism.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import app.tourism.db.entities.Place
import kotlinx.coroutines.flow.Flow
@Dao
interface PlaceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPlaces(places: List<Place>)
@Query("DELETE FROM Places")
suspend fun deleteAllPlaces()
@Query("SELECT * FROM Places")
suspend fun getAllPlaces(): Flow<List<Place>>
@Query("SELECT * FROM Places WHERE id = :placeId")
suspend fun getPlaceById(placeId: Long): Flow<Place>
@Query("SELECT * FROM Places WHERE isFavorite == 1")
suspend fun getFavoritePlaces(): Flow<List<Place>>
@Update
suspend fun setFavorite(placeId: Long, isFavorite: Boolean)
}

View file

@ -1,15 +0,0 @@
package app.tourism.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "feedbacks")
data class Feedback(
@PrimaryKey(autoGenerate = true) val id: Long,
val userId: Long,
val message: String,
val placeId: Long,
val points: Int,
val images: List<String>
)

View file

@ -1,26 +0,0 @@
package app.tourism.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Relation
@Entity(tableName = "Places")
data class Place(
@PrimaryKey(autoGenerate = true) val id: Long,
val name: String,
val phone: String,
val shortDescription: String,
val longDescription: String,
val cover: String,
val gallery: List<String>,
@Relation(parentColumn = "id", entityColumn = "placeId", entity = Feedback::class)
val feedbacks: List<Feedback>,
val coordinates: Coordinates,
val rating: Double,
val isFavorite: Boolean
)
data class Coordinates(
val latitude: String,
val longitude: String
)

View file

@ -1,12 +0,0 @@
package app.tourism.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "users")
data class User(
@PrimaryKey(autoGenerate = true) val id: Long,
val fullName: String,
val avatar: String,
val country: String
)

View file

@ -2,7 +2,7 @@ package app.tourism.di
import android.app.Application
import androidx.room.Room
import app.tourism.db.Database
import app.tourism.data.db.Database
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn

View file

@ -6,7 +6,7 @@ import app.tourism.data.prefs.UserPreferences
import app.tourism.data.remote.CurrencyApi
import app.tourism.data.remote.TourismApi
import app.tourism.data.repositories.CurrencyRepository
import app.tourism.db.Database
import app.tourism.data.db.Database
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn

View file

@ -7,7 +7,9 @@ import app.tourism.data.remote.TourismApi
import app.tourism.data.repositories.AuthRepository
import app.tourism.data.repositories.CurrencyRepository
import app.tourism.data.repositories.ProfileRepository
import app.tourism.db.Database
import app.tourism.data.db.Database
import app.tourism.data.repositories.PlacesRepository
import app.tourism.data.repositories.ReviewsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -24,6 +26,27 @@ object RepositoriesModule {
return AuthRepository(api)
}
@Provides
@Singleton
fun providePlacesRepository(
api: TourismApi,
db: Database,
@ApplicationContext context: Context,
): PlacesRepository {
return PlacesRepository(api, db, context)
}
@Provides
@Singleton
fun provideReviewsRepository(
api: TourismApi,
db: Database,
@ApplicationContext context: Context,
): ReviewsRepository {
return ReviewsRepository(api, db)
}
@Provides
@Singleton
fun provideProfileRepository(

View file

@ -1,3 +1,9 @@
package app.tourism.domain.models.categories
data class Category(val value: String?, val label: String)
enum class PlaceCategory(val id: Long) {
Sights(1), // called attractions in the server
Restaurants(2),
Hotels(3) // called accommodations in the server
}

View file

@ -3,7 +3,7 @@ package app.tourism.domain.models.common
data class PlaceShort(
val id: Long,
val name: String,
val pic: String? = null,
val cover: String? = null,
val rating: Double? = null,
val excerpt: String? = null,
val isFavorite: Boolean = false,

View file

@ -1,15 +1,40 @@
package app.tourism.domain.models.details
import app.tourism.data.db.entities.PlaceEntity
import app.tourism.data.dto.PlaceLocation
import app.tourism.domain.models.common.PlaceShort
data class PlaceFull(
val id: Long,
val name: String,
val rating: Double? = null,
val excerpt: String? = null,
val description: String? = null,
val placeLocation: PlaceLocation? = null,
val pic: String? = null,
val rating: Double,
val excerpt: String,
val description: String,
val placeLocation: PlaceLocation,
val cover: String,
val pics: List<String> = emptyList(),
val isFavorite: Boolean = false,
)
val reviews: List<Review>? = null,
val isFavorite: Boolean,
) {
fun toPlaceShort() = PlaceShort(
id = id,
name = name,
cover = cover,
rating = rating,
excerpt = excerpt,
isFavorite = isFavorite
)
fun toPlaceEntity(categoryId: Long) = PlaceEntity(
id = id,
categoryId = categoryId,
name = name,
rating = rating,
excerpt = excerpt,
description = description,
gallery = pics,
coordinates = placeLocation.toCoordinatesEntity(),
cover = cover,
isFavorite = isFavorite,
)
}

View file

@ -1,10 +1,23 @@
package app.tourism.domain.models.details
import app.tourism.data.db.entities.ReviewEntity
data class Review(
val id: Long,
val rating: Double? = null,
val placeId: Long,
val rating: Int,
val user: User,
val date: String? = null,
val comment: String? = null,
val picsUrls: List<String> = emptyList(),
)
) {
fun toReviewEntity() = ReviewEntity(
id = id,
user = user.toUserEntity(),
comment = comment ?: "",
placeId = placeId,
date = date ?: "",
rating = rating,
images = picsUrls
)
}

View file

@ -0,0 +1,10 @@
package app.tourism.domain.models.details
import java.io.File
data class ReviewToPost(
val placeId: Long,
val comment: String,
val rating: Int,
val images: List<File>,
)

View file

@ -1,8 +1,14 @@
package app.tourism.domain.models.details
import app.tourism.data.db.entities.JustUser
data class User(
val id: Long,
val name: String,
val pfpUrl: String? = null,
val countryCodeName: String? = null,
)
val countryCodeName: String,
) {
fun toUserEntity() = JustUser(
userId = id, fullName = name, avatar = pfpUrl, country = countryCodeName
)
}

View file

@ -1,6 +1,6 @@
package app.tourism.domain.models.profile
import app.tourism.db.entities.CurrencyRatesEntity
import app.tourism.data.db.entities.CurrencyRatesEntity
data class CurrencyRates(val usd: Double, val eur: Double, val rub: Double) {
fun toCurrencyRatesEntity() = CurrencyRatesEntity(1, usd, eur, rub)

View file

@ -1,23 +1,21 @@
package app.tourism.ui.common
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.organicmaps.R
import app.tourism.ui.theme.TextStyles
import coil.compose.AsyncImage
@ -27,10 +25,10 @@ import coil.request.ImageRequest
fun LoadImg(
url: String?,
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.surface,
backgroundColor: Color = Color.Transparent,
contentScale: ContentScale = ContentScale.Crop
) {
if (url != null && url.isNotBlank())
if (!url.isNullOrBlank())
CoilImg(
modifier = modifier,
url = url,
@ -47,7 +45,7 @@ fun LoadImg(
) {
Text(
text = stringResource(id = R.string.no_image),
style = TextStyles.b2,
style = TextStyles.b3,
textAlign = TextAlign.Center
)
}
@ -61,10 +59,13 @@ fun CoilImg(
contentScale: ContentScale
) {
AsyncImage(
modifier = modifier.background(color = backgroundColor),
modifier = Modifier
.background(color = backgroundColor)
.then(modifier)
.fillMaxSize(),
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(500)
.crossfade(200)
.error(R.drawable.error_centered)
.build(),
placeholder = painterResource(R.drawable.placeholder),

View file

@ -29,7 +29,7 @@ fun AppSearchBar(
modifier: Modifier = Modifier,
query: String,
onQueryChanged: (String) -> Unit,
onSearchClicked: (String) -> Unit,
onSearchClicked: ((String) -> Unit)? = null,
onClearClicked: () -> Unit,
) {
var isActive by remember { mutableStateOf(false) }
@ -51,7 +51,7 @@ fun AppSearchBar(
singleLine = true,
maxLines = 1,
leadingIcon = {
IconButton(onClick = { onSearchClicked(query) }) {
IconButton(onClick = { onSearchClicked?.invoke(query) }) {
Icon(
painter = painterResource(id = R.drawable.search),
contentDescription = searchLabel,
@ -69,7 +69,7 @@ fun AppSearchBar(
}
},
shape = RoundedCornerShape(16.dp),
keyboardActions = KeyboardActions(onSearch = { onSearchClicked(query) }),
keyboardActions = KeyboardActions(onSearch = { onSearchClicked?.invoke(query) }),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.organicmaps.R
import app.tourism.ui.common.LoadImg
@ -96,7 +97,9 @@ fun PlaceTopBar(
.padding(start = padding, end = padding, bottom = padding),
text = title,
style = TextStyles.h2,
color = Color.White
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View file

@ -23,7 +23,7 @@ fun SearchTopBar(
modifier: Modifier = Modifier,
query: String,
onQueryChanged: (String) -> Unit,
onSearchClicked: (String) -> Unit,
onSearchClicked: ((String) -> Unit)? = null,
onClearClicked: () -> Unit,
onBackClicked: () -> Unit
) {
@ -60,7 +60,7 @@ fun SearchTopBar(
)
}
},
keyboardActions = KeyboardActions(onSearch = { onSearchClicked(query) }),
keyboardActions = KeyboardActions(onSearch = { onSearchClicked?.invoke(query) }),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.background,

View file

@ -50,9 +50,12 @@ fun PlacesItem(
.clickable { onPlaceClick() }
.then(modifier)
) {
LoadImg(modifier = Modifier
.size(height)
.clip(shape), url = place.pic)
LoadImg(
modifier = Modifier
.size(height)
.clip(shape),
url = place.cover,
)
Column(
Modifier
.fillMaxHeight(0.9f)

View file

@ -17,7 +17,7 @@ import app.tourism.ui.theme.getStarColor
@Composable
fun RatingBar(
rating: Float,
rating: Int,
size: Dp = 30.dp,
maxRating: Int = 5,
onRatingChanged: ((Float) -> Unit)? = null,

View file

@ -1,6 +1,6 @@
package app.tourism.ui.models
data class SingleChoiceItem(
val key: String?,
val key: Any,
val label: String
)

View file

@ -18,12 +18,12 @@ import app.tourism.ui.screens.language.LanguageScreen
import app.tourism.ui.screens.main.categories.categories.CategoriesScreen
import app.tourism.ui.screens.main.categories.categories.CategoriesViewModel
import app.tourism.ui.screens.main.favorites.favorites.FavoritesScreen
import app.tourism.ui.screens.main.home.home.HomeScreen
import app.tourism.ui.screens.main.home.search.SearchScreen
import app.tourism.ui.screens.main.home.HomeScreen
import app.tourism.ui.screens.main.place_details.PlaceDetailsScreen
import app.tourism.ui.screens.main.profile.personal_data.PersonalDataScreen
import app.tourism.ui.screens.main.profile.profile.ProfileScreen
import app.tourism.ui.screens.main.profile.profile.ProfileViewModel
import app.tourism.ui.screens.main.search.SearchScreen
import app.tourism.utils.navigateToMap
import app.tourism.utils.navigateToMapForRoute
import kotlinx.serialization.Serializable
@ -63,27 +63,32 @@ fun MainNavigation(rootNavController: NavHostController, themeVM: ThemeViewModel
val onPlaceClick: (id: Long) -> Unit = { id ->
rootNavController.navigate(PlaceDetails(id = id))
}
val onSearchClick: (q: String) -> Unit = { q ->
rootNavController.navigate(Search(query = q))
}
val onMapClick = { navigateToMap(context) }
val onBackClick: () -> Unit = { rootNavController.navigateUp() }
NavHost(rootNavController, startDestination = "home_tab") {
composable("home_tab") {
HomeNavHost(
onPlaceClick,
onSearchClick,
onMapClick,
onCategoryClicked = {
rootNavController.navigate("categories_tab") {
popUpTo(rootNavController.graph.findStartDestination().id) {
saveState = true
popUpTo(rootNavController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
launchSingleTop = true
restoreState = true
}
},
categoriesVM,
)
}
composable("categories_tab") {
CategoriesNavHost(onPlaceClick, onMapClick, categoriesVM)
CategoriesNavHost(onPlaceClick, onSearchClick, onMapClick, categoriesVM)
}
composable("favorites_tab") {
FavoritesNavHost(onPlaceClick)
@ -95,40 +100,18 @@ fun MainNavigation(rootNavController: NavHostController, themeVM: ThemeViewModel
val placeDetails = backStackEntry.toRoute<PlaceDetails>()
PlaceDetailsScreen(
id = placeDetails.id,
onBackClick = { rootNavController.navigateUp() },
onBackClick = onBackClick,
onMapClick = onMapClick,
onCreateRoute = { placeLocation ->
navigateToMapForRoute(context, placeLocation)
}
)
}
}
}
@Composable
fun HomeNavHost(
onPlaceClick: (id: Long) -> Unit,
onMapClick: () -> Unit,
onCategoryClicked: () -> Unit,
categoriesVM: CategoriesViewModel,
) {
val homeNavController = rememberNavController()
NavHost(homeNavController, startDestination = Home) {
composable<Home> {
HomeScreen(
onSearchClick = { query ->
homeNavController.navigate(Search(query = query))
},
onPlaceClick = onPlaceClick,
onMapClick = onMapClick,
onCategoryClicked = onCategoryClicked,
categoriesVM = categoriesVM
)
}
composable<Search> { backStackEntry ->
val search = backStackEntry.toRoute<Search>()
SearchScreen(
onPlaceClick = onPlaceClick,
onBackClick = onBackClick,
onMapClick = onMapClick,
queryArg = search.query,
)
@ -136,16 +119,39 @@ fun HomeNavHost(
}
}
@Composable
fun HomeNavHost(
onPlaceClick: (id: Long) -> Unit,
onSearchClick: (String) -> Unit,
onMapClick: () -> Unit,
onCategoryClicked: () -> Unit,
categoriesVM: CategoriesViewModel,
) {
val homeNavController = rememberNavController()
NavHost(homeNavController, startDestination = Home) {
composable<Home> {
HomeScreen(
onSearchClick = onSearchClick,
onPlaceClick = onPlaceClick,
onMapClick = onMapClick,
onCategoryClicked = onCategoryClicked,
categoriesVM = categoriesVM
)
}
}
}
@Composable
fun CategoriesNavHost(
onPlaceClick: (id: Long) -> Unit,
onSearchClick: (String) -> Unit,
onMapClick: () -> Unit,
categoriesVM: CategoriesViewModel,
) {
val categoriesNavController = rememberNavController()
NavHost(categoriesNavController, startDestination = Categories) {
composable<Categories> {
CategoriesScreen(onPlaceClick, onMapClick, categoriesVM)
CategoriesScreen(onPlaceClick, onSearchClick, onMapClick, categoriesVM)
}
}
}

View file

@ -21,6 +21,9 @@ class ThemeViewModel @Inject constructor(
fun setTheme(themeCode: String) {
_theme.value = userPreferences.themes.first { it.code == themeCode }
userPreferences.setTheme(themeCode)
}
fun updateThemeOnServer(themeCode: String){
viewModelScope.launch {
profileRepository.updateTheme(themeCode)
}

View file

@ -1,13 +1,17 @@
package app.tourism.ui.screens.main.categories.categories
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -25,6 +29,7 @@ import app.tourism.ui.common.special.PlacesItem
@Composable
fun CategoriesScreen(
onPlaceClick: (id: Long) -> Unit,
onSearchClick: (String) -> Unit,
onMapClick: () -> Unit,
categoriesVM: CategoriesViewModel = hiltViewModel()
) {
@ -34,6 +39,11 @@ fun CategoriesScreen(
val selectedCategory = selectedCategory.collectAsState().value
val places = places.collectAsState().value
LaunchedEffect(true) {
if (selectedCategory == null)
categoriesVM.setSelectedCategory(categories.first())
}
Scaffold(
topBar = {
AppTopBar(
@ -47,26 +57,28 @@ fun CategoriesScreen(
),
)
},
contentWindowInsets = Constants.USUAL_WINDOW_INSETS
contentWindowInsets = WindowInsets(bottom = 0.dp)
) { paddingValues ->
LazyColumn(
Modifier
.padding(paddingValues),
) {
LazyColumn(Modifier.padding(paddingValues)) {
item {
Column {
VerticalSpace(height = 16.dp)
AppSearchBar(
modifier = Modifier.fillMaxWidth(),
query = query,
onQueryChanged = ::setQuery,
onSearchClicked = ::search,
onClearClicked = ::clearSearchField,
)
VerticalSpace(height = 16.dp)
Column(modifier = Modifier.padding(horizontal = Constants.SCREEN_PADDING)) {
VerticalSpace(height = 16.dp)
AppSearchBar(
modifier = Modifier.fillMaxWidth(),
query = query,
onQueryChanged = ::setQuery,
onSearchClicked = onSearchClick,
onClearClicked = ::clearSearchField,
)
VerticalSpace(height = 16.dp)
}
HorizontalSingleChoice(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = Constants.SCREEN_PADDING),
items = categories,
selected = selectedCategory,
onSelectedChanged = ::setSelectedCategory,
@ -76,7 +88,7 @@ fun CategoriesScreen(
}
items(places) { item ->
Column {
Column(modifier = Modifier.padding(horizontal = Constants.SCREEN_PADDING)) {
PlacesItem(
place = item,
onPlaceClick = { onPlaceClick(item.id) },

View file

@ -1,19 +1,29 @@
package app.tourism.ui.screens.main.categories.categories
import android.content.Context
import androidx.lifecycle.ViewModel
import app.tourism.Constants
import androidx.lifecycle.viewModelScope
import app.organicmaps.R
import app.tourism.data.repositories.PlacesRepository
import app.tourism.domain.models.categories.PlaceCategory
import app.tourism.domain.models.common.PlaceShort
import app.tourism.domain.models.resource.Resource
import app.tourism.ui.models.SingleChoiceItem
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class CategoriesViewModel @Inject constructor(
@ApplicationContext context: Context,
private val placesRepository: PlacesRepository,
) : ViewModel() {
private val uiChannel = Channel<UiEvent>()
val uiEventsChannelFlow = uiChannel.receiveAsFlow()
@ -26,10 +36,6 @@ class CategoriesViewModel @Inject constructor(
_query.value = value
}
fun search(value: String) {
// todo
}
fun clearSearchField() {
_query.value = ""
}
@ -51,30 +57,34 @@ class CategoriesViewModel @Inject constructor(
val places = _places.asStateFlow()
fun setFavoriteChanged(item: PlaceShort, isFavorite: Boolean) {
// todo
viewModelScope.launch(Dispatchers.IO) {
placesRepository.setFavorite(item.id, isFavorite)
}
}
private fun onCategoryChangeGetPlaces() {
viewModelScope.launch(Dispatchers.IO) {
_selectedCategory.collectLatest { item ->
item?.key?.let { id ->
val categoryId = id as Long
placesRepository.getPlacesByCategory(categoryId).collectLatest { resource ->
if (resource is Resource.Success) {
resource.data?.let { _places.value = it }
}
}
}
}
}
}
init {
// todo replace with real data
_selectedCategory.value = SingleChoiceItem("sights", "Sights")
_categories.value = listOf(
SingleChoiceItem("sights", "Sights"),
SingleChoiceItem("restaurants", "Restaurants"),
SingleChoiceItem("hotels", "Hotels"),
SingleChoiceItem(PlaceCategory.Sights.id, context.getString(R.string.sights)),
SingleChoiceItem(PlaceCategory.Restaurants.id, context.getString(R.string.restaurants)),
SingleChoiceItem(PlaceCategory.Hotels.id, context.getString(R.string.hotels)),
)
val dummyData = mutableListOf<PlaceShort>()
repeat(15) {
dummyData.add(
PlaceShort(
id = it.toLong(),
name = "Гора Эмина",
pic = Constants.IMAGE_URL_EXAMPLE,
rating = 5.0,
excerpt = "завтрак включен, бассейн, сауна, с видом на озеро"
)
)
}
_places.update { dummyData }
_selectedCategory.value = categories.value.first()
onCategoryChangeGetPlaces()
}
}

View file

@ -2,6 +2,7 @@ package app.tourism.ui.screens.main.categories.categories
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@ -27,7 +28,7 @@ fun HorizontalSingleChoice(
unselectedColor: Color = MaterialTheme.colorScheme.background,
itemModifier: Modifier = Modifier,
) {
Row(Modifier.then(modifier)) {
Row(Modifier.then(modifier), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items.forEach {
SingleChoiceItem(
modifier = itemModifier,
@ -39,7 +40,6 @@ fun HorizontalSingleChoice(
selectedColor = selectedColor,
unselectedColor = unselectedColor
)
HorizontalSpace(width = 12.dp)
}
}
}

View file

@ -1,9 +1,11 @@
package app.tourism.ui.screens.main.favorites.favorites
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@ -30,6 +32,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FavoritesScreen(
onPlaceClick: (id: Long) -> Unit,
@ -51,9 +54,11 @@ fun FavoritesScreen(
modifier = Modifier.focusRequester(focusRequester),
query = query,
onQueryChanged = { favoritesVM.setQuery(it) },
onSearchClicked = { favoritesVM.search(it) },
onClearClicked = { favoritesVM.clearSearchField() },
onBackClicked = { isSearchActive = false },
onBackClicked = {
isSearchActive = false
favoritesVM.setQuery("")
},
)
}
} else {
@ -83,8 +88,8 @@ fun FavoritesScreen(
VerticalSpace(16.dp)
}
items(places) { item ->
Column {
items(places, key = { it.id }) { item ->
Column(Modifier.animateItem()) {
PlacesItem(
place = item,
onPlaceClick = { onPlaceClick(item.id) },

View file

@ -1,18 +1,23 @@
package app.tourism.ui.screens.main.favorites.favorites
import androidx.lifecycle.ViewModel
import app.tourism.Constants
import androidx.lifecycle.viewModelScope
import app.tourism.data.repositories.PlacesRepository
import app.tourism.domain.models.common.PlaceShort
import app.tourism.domain.models.resource.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class FavoritesViewModel @Inject constructor(
private val placesRepository: PlacesRepository
) : ViewModel() {
private val uiChannel = Channel<UiEvent>()
val uiEventsChannelFlow = uiChannel.receiveAsFlow()
@ -25,10 +30,6 @@ class FavoritesViewModel @Inject constructor(
_query.value = value
}
fun search(value: String) {
// todo
}
fun clearSearchField() {
_query.value = ""
}
@ -38,24 +39,25 @@ class FavoritesViewModel @Inject constructor(
val places = _places.asStateFlow()
fun setFavoriteChanged(item: PlaceShort, isFavorite: Boolean) {
// todo
viewModelScope.launch(Dispatchers.IO) {
placesRepository.setFavorite(item.id, isFavorite)
}
}
private fun getFavorites() {
viewModelScope.launch(Dispatchers.IO) {
_query.collectLatest {
placesRepository.getFavorites(it).collectLatest { resource ->
if (resource is Resource.Success) {
resource.data?.let { _places.value = it }
}
}
}
}
}
init {
// todo replace with real data
val dummyData = mutableListOf<PlaceShort>()
repeat(15) {
dummyData.add(
PlaceShort(
id = it.toLong(),
name = "Гиссарская крепость",
pic = Constants.IMAGE_URL_EXAMPLE,
rating = 5.0,
excerpt = "завтрак включен, бассейн, сауна, с видом на озеро"
)
)
}
_places.update { dummyData }
getFavorites()
}
}

View file

@ -1,4 +1,4 @@
package app.tourism.ui.screens.main.home.home
package app.tourism.ui.screens.main.home
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -100,12 +100,7 @@ fun HomeScreen(
modifier = Modifier.fillMaxWidth(),
query = query,
onQueryChanged = { homeVM.setQuery(it) },
onSearchClicked = {
// search field will be cleared only here
// when it navigates to SearchScreen query value will be preserved there
homeVM.clearSearchField()
onSearchClick(it)
},
onSearchClicked = onSearchClick,
onClearClicked = { homeVM.clearSearchField() },
)
}
@ -121,7 +116,7 @@ fun HomeScreen(
onPlaceClick(item.id)
},
setFavoriteChanged = { item, isFavorite ->
homeVM.setFavoriteChanged(item, isFavorite)
},
)
VerticalSpace(height = 24.dp)
@ -133,7 +128,7 @@ fun HomeScreen(
onPlaceClick(item.id)
},
setFavoriteChanged = { item, isFavorite ->
homeVM.setFavoriteChanged(item, isFavorite)
},
)
@ -192,7 +187,7 @@ private fun HorizontalPlaces(
Place(
place = it,
onPlaceClick = { onPlaceClick(it) },
isFavorite = false,
isFavorite = it.isFavorite,
onFavoriteChanged = { isFavorite ->
setFavoriteChanged(it, isFavorite)
},
@ -226,7 +221,7 @@ private fun Place(
.clickable { onPlaceClick() }
.then(modifier),
) {
LoadImg(url = place.pic)
LoadImg(url = place.cover)
Column(
Modifier
@ -241,6 +236,7 @@ private fun Place(
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
VerticalSpace(height = 4.dp)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "%.1f".format(place.rating), style = textStyle)
HorizontalSpace(width = 2.dp)
@ -263,7 +259,8 @@ private fun Place(
},
) {
Icon(
painterResource(id = if (isFavorite) R.drawable.heart_selected else R.drawable.heart),
modifier = Modifier.size(20.dp),
painter = painterResource(id = if (isFavorite) R.drawable.heart_selected else R.drawable.heart),
contentDescription = stringResource(id = R.string.add_to_favorites),
tint = Color.White,
)

View file

@ -0,0 +1,93 @@
package app.tourism.ui.screens.main.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.tourism.data.repositories.PlacesRepository
import app.tourism.domain.models.SimpleResponse
import app.tourism.domain.models.categories.PlaceCategory
import app.tourism.domain.models.common.PlaceShort
import app.tourism.domain.models.resource.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
private val placesRepository: PlacesRepository
) : ViewModel() {
private val uiChannel = Channel<UiEvent>()
val uiEventsChannelFlow = uiChannel.receiveAsFlow()
// region search query
private val _query = MutableStateFlow("")
val query = _query.asStateFlow()
fun setQuery(value: String) {
_query.value = value
}
fun clearSearchField() {
_query.value = ""
}
// endregion search query
private val _sights = MutableStateFlow<List<PlaceShort>>(emptyList())
val sights = _sights.asStateFlow()
private fun getTopSights() {
viewModelScope.launch(Dispatchers.IO) {
placesRepository.getTopPlaces(id = PlaceCategory.Sights.id)
.collectLatest { resource ->
if (resource is Resource.Success) {
resource.data?.let { _sights.value = it }
}
}
}
}
private val _restaurants = MutableStateFlow<List<PlaceShort>>(emptyList())
val restaurants = _restaurants.asStateFlow()
private fun getTopRestaurants() {
viewModelScope.launch(Dispatchers.IO) {
placesRepository.getTopPlaces(id = PlaceCategory.Restaurants.id)
.collectLatest { resource ->
if (resource is Resource.Success) {
resource.data?.let { _restaurants.value = it }
}
}
}
}
private val _downloadResponse = MutableStateFlow<Resource<SimpleResponse>?>(null)
val downloadResponse = _downloadResponse.asStateFlow()
private fun downloadAllDataIfFirstTime() {
viewModelScope.launch(Dispatchers.IO) {
placesRepository.downloadAllDataIfFirstTime().collectLatest {
_downloadResponse.value = it
}
}
}
fun setFavoriteChanged(item: PlaceShort, isFavorite: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
placesRepository.setFavorite(item.id, isFavorite)
}
}
init {
downloadAllDataIfFirstTime()
getTopSights()
getTopRestaurants()
}
}
sealed interface UiEvent {
data class ShowToast(val message: String) : UiEvent
}

View file

@ -1,68 +0,0 @@
package app.tourism.ui.screens.main.home.home
import androidx.lifecycle.ViewModel
import app.tourism.Constants
import app.tourism.domain.models.common.PlaceShort
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
) : ViewModel() {
private val uiChannel = Channel<UiEvent>()
val uiEventsChannelFlow = uiChannel.receiveAsFlow()
// region search query
private val _query = MutableStateFlow("")
val query = _query.asStateFlow()
fun setQuery(value: String) {
_query.value = value
}
fun search(value: String) {
// todo
}
fun clearSearchField() {
_query.value = ""
}
// endregion search query
private val _sights = MutableStateFlow<List<PlaceShort>>(emptyList())
val sights = _sights.asStateFlow()
private val _restaurants = MutableStateFlow<List<PlaceShort>>(emptyList())
val restaurants = _restaurants.asStateFlow()
fun setFavoriteChanged(item: PlaceShort, isFavorite: Boolean) {
// todo
}
init {
// todo replace with real data
val dummyData = mutableListOf<PlaceShort>()
repeat(15) {
dummyData.add(
PlaceShort(
id = it.toLong(),
name = "Гора Эмина",
pic = Constants.IMAGE_URL_EXAMPLE,
rating = 5.0,
excerpt = "завтрак включен, бассейн, сауна, с видом на озеро"
)
)
}
_sights.update { dummyData }
_restaurants.update { dummyData }
}
}
sealed interface UiEvent {
data class ShowToast(val message: String) : UiEvent
}

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -37,14 +38,18 @@ fun PlaceDetailsScreen(
val place = placeVM.place.collectAsState().value
LaunchedEffect(true) {
placeVM.observePlace(id)
}
Scaffold(
topBar = {
place?.let {
PlaceTopBar(
title = it.name,
picUrl = it.pic,
picUrl = it.cover,
isFavorite = it.isFavorite,
onFavoriteChanged = { placeVM.setFavoriteChanged(id, it) },
onFavoriteChanged = { isFavorite -> placeVM.setFavoriteChanged(id, isFavorite) },
onMapClick = onMapClick,
onBackClick = onBackClick,
)
@ -57,7 +62,7 @@ fun PlaceDetailsScreen(
val pagerState = rememberPagerState(pageCount = { 3 })
VerticalSpace(height = 16.dp)
Box(modifier = Modifier.padding(horizontal = Constants.SCREEN_PADDING)){
Box(modifier = Modifier.padding(horizontal = Constants.SCREEN_PADDING)) {
PlaceTabRow(
tabIndex = pagerState.currentPage,
onTabIndexChanged = {
@ -79,7 +84,7 @@ fun PlaceDetailsScreen(
DescriptionScreen(
description = place.description,
onCreateRoute = {
place.placeLocation?.let { onCreateRoute(it) }
onCreateRoute(place.placeLocation)
},
)
}

View file

@ -22,9 +22,9 @@ import app.tourism.ui.theme.TextStyles
@Composable
fun PlaceTabRow(modifier: Modifier = Modifier, tabIndex: Int, onTabIndexChanged: (Int) -> Unit) {
val tabs = listOf(
SingleChoiceItem("0", stringResource(id = R.string.description_tourism)),
SingleChoiceItem("1", stringResource(id = R.string.gallery)),
SingleChoiceItem("2", stringResource(id = R.string.reviews)),
SingleChoiceItem(0, stringResource(id = R.string.description_tourism)),
SingleChoiceItem(1, stringResource(id = R.string.gallery)),
SingleChoiceItem(2, stringResource(id = R.string.reviews)),
)
val shape = RoundedCornerShape(50.dp)
@ -40,12 +40,9 @@ fun PlaceTabRow(modifier: Modifier = Modifier, tabIndex: Int, onTabIndexChanged:
tabs.forEach {
SingleChoiceItem(
item = it,
isSelected = it.key?.toInt() == tabIndex,
isSelected = it.key == tabIndex,
onClick = {
val key = it.key?.toInt()
if (key != null) {
onTabIndexChanged(key)
}
onTabIndexChanged(it.key as Int)
},
)
}

View file

@ -1,20 +1,27 @@
package app.tourism.ui.screens.main.place_details
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.tourism.Constants
import app.tourism.data.dto.PlaceLocation
import app.tourism.data.repositories.PlacesRepository
import app.tourism.domain.models.details.PlaceFull
import app.tourism.domain.models.resource.Resource
import app.tourism.utils.makeLongListOfTheSameItem
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PlaceViewModel @Inject constructor(
private val placesRepository: PlacesRepository
) : ViewModel() {
private val uiChannel = Channel<UiEvent>()
val uiEventsChannelFlow = uiChannel.receiveAsFlow()
@ -23,26 +30,18 @@ class PlaceViewModel @Inject constructor(
val place = _place.asStateFlow()
fun setFavoriteChanged(itemId: Long, isFavorite: Boolean) {
// todo
viewModelScope.launch(Dispatchers.IO) {
placesRepository.setFavorite(itemId, isFavorite)
}
}
init {
//todo replace with real data
val galleryPics = makeLongListOfTheSameItem(Constants.IMAGE_URL_EXAMPLE, 15).toMutableList()
galleryPics.add(Constants.THUMBNAIL_URL_EXAMPLE)
galleryPics.add(Constants.THUMBNAIL_URL_EXAMPLE)
galleryPics.add(Constants.IMAGE_URL_EXAMPLE)
_place.update {
PlaceFull(
id = 1,
name = "Гора Эмина",
rating = 5.0,
pic = Constants.IMAGE_URL_EXAMPLE,
excerpt = null,
description = htmlExample,
placeLocation = PlaceLocation(name = "Гора Эмина", lat = 38.579, lon = 68.782),
pics = galleryPics,
)
fun observePlace(id: Long) {
viewModelScope.launch(Dispatchers.IO) {
placesRepository.getPlaceById(id).collectLatest {
if(it is Resource.Success) {
_place.value = it.data
}
}
}
}
}
@ -50,25 +49,3 @@ class PlaceViewModel @Inject constructor(
sealed interface UiEvent {
data class ShowToast(val message: String) : UiEvent
}
val htmlExample =
"""
<!DOCTYPE html>
<html lang="tg">
<head>
<meta charset="UTF-8">
</head>
<body>
<h2>Гиссарская крепость</h2>
<p> 4,8 работает каждый день, с 8:00 по 17:00</p>
<h3>О месте</h3>
<p>Город республиканского подчинения в западной части Таджикистана, в 20 километрах от столицы.</p>
<p>Город славится историческими достопримечательностями, например, Гиссарской крепостью, которая считается одним из самых известных исторических сооружений в Центральной Азии.</p>
<h3>Адрес</h3>
<p>районы республиканского подчинения, город Гиссар с административной территорией, джамоат Хисор, село Гиссар</p>
<h3>Контакты</h3>
<p>+992 998 201 201</p>
</body>
</html>
""".trimIndent()

View file

@ -1,17 +1,30 @@
package app.tourism.ui.screens.main.place_details.reviews
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.organicmaps.R
import app.tourism.data.repositories.ReviewsRepository
import app.tourism.domain.models.SimpleResponse
import app.tourism.domain.models.details.ReviewToPost
import app.tourism.domain.models.resource.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
@HiltViewModel
class PostReviewViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val reviewsRepository: ReviewsRepository
) : ViewModel() {
private val uiChannel = Channel<UiEvent>()
val uiEventsChannelFlow = uiChannel.receiveAsFlow()
@ -51,7 +64,31 @@ class PostReviewViewModel @Inject constructor(
}
}
fun postReview() {
private val _postReviewResponse = MutableStateFlow<Resource<SimpleResponse>?>(null)
val postReviewResponse = _postReviewResponse.asStateFlow()
fun postReview(id: Long) {
viewModelScope.launch(Dispatchers.Unconfined) {
reviewsRepository.postReview(
ReviewToPost(
placeId = id,
comment = _comment.value,
rating = _rating.value.toInt(),
images = _files.value
)
).collectLatest {
_postReviewResponse.value = it
if (it is Resource.Success) {
uiChannel.send(
UiEvent.ShowToast(it.message ?: context.getString(R.string.great_success))
)
uiChannel.send(UiEvent.CloseReviewBottomSheet)
} else if (it is Resource.Error) {
uiChannel.send(
UiEvent.ShowToast(it.message ?: context.getString(R.string.smth_went_wrong))
)
}
}
}
}
}

View file

@ -1,6 +1,7 @@
package app.tourism.ui.screens.main.place_details.reviews
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -30,6 +31,10 @@ fun ReviewsNavigation(
navController.navigate(ReviewPics(urls = it))
}
LaunchedEffect(Unit) {
reviewsVM.getReviews(placeId)
}
NavHost(navController = navController, startDestination = Reviews) {
composable<Reviews> {
ReviewsScreen(

View file

@ -38,6 +38,7 @@ import app.tourism.ui.theme.TextStyles
import app.tourism.ui.theme.getStarColor
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -93,7 +94,7 @@ fun ReviewsScreen(
Text(text = stringResource(id = R.string.compose_review))
}
RatingBar(rating = it.toFloat())
RatingBar(rating = it.roundToInt())
}
VerticalSpace(height = 24.dp)
@ -111,13 +112,18 @@ fun ReviewsScreen(
userReview?.let {
item {
Review(review = userReview, onMoreClick = onMoreClick)
Review(
review = userReview,
onMoreClick = onMoreClick,
onDeleteClick = {}
)
}
}
items(3) {
Review(review = reviews[it], onMoreClick = onMoreClick)
}
if (reviews.firstOrNull() != null)
item {
Review(review = reviews[0], onMoreClick = onMoreClick)
}
}
if (showReviewBottomSheet)
@ -126,6 +132,11 @@ fun ReviewsScreen(
containerColor = MaterialTheme.colorScheme.background,
onDismissRequest = { showReviewBottomSheet = false },
) {
PostReview()
PostReview(
placeId,
onPostReviewSuccess = {
showReviewBottomSheet = false
},
)
}
}

View file

@ -1,20 +1,23 @@
package app.tourism.ui.screens.main.place_details.reviews
import androidx.lifecycle.ViewModel
import app.tourism.Constants
import androidx.lifecycle.viewModelScope
import app.tourism.data.repositories.ReviewsRepository
import app.tourism.domain.models.details.Review
import app.tourism.domain.models.details.User
import app.tourism.utils.makeLongListOfTheSameItem
import app.tourism.utils.toUserFriendlyDate
import app.tourism.domain.models.resource.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ReviewsViewModel @Inject constructor(
private val reviewsRepository: ReviewsRepository
) : ViewModel() {
private val uiChannel = Channel<UiEvent>()
val uiEventsChannelFlow = uiChannel.receiveAsFlow()
@ -25,23 +28,13 @@ class ReviewsViewModel @Inject constructor(
private val _userReview = MutableStateFlow<Review?>(null)
val userReview = _userReview.asStateFlow()
init {
//todo replace with real data
_reviews.value = makeLongListOfTheSameItem(
Review(
id = 1,
rating = 5.0,
user = User(
id = 1,
name = "Эмин Уайт",
pfpUrl = Constants.IMAGE_URL_EXAMPLE,
countryCodeName = "tj",
),
date = "2024-06-06".toUserFriendlyDate(),
comment = "Это было прекрасное место! Мне очень понравилось, обязательно поситите это место gnjfhjgefkjgnjcsld\n" +
"Это было прекрасное место! Мне очень понравилось, обязательно поситите это место.",
picsUrls = makeLongListOfTheSameItem(Constants.IMAGE_URL_EXAMPLE, 5)
)
)
fun getReviews(id: Long) {
viewModelScope.launch(Dispatchers.IO) {
reviewsRepository.getReviewsForPlace(id).collectLatest {
if (it is Resource.Success) {
it.data?.let { _reviews.value = it }
}
}
}
}
}

View file

@ -26,39 +26,57 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.organicmaps.R
import app.tourism.Constants
import app.tourism.domain.models.resource.Resource
import app.tourism.ui.ObserveAsEvents
import app.tourism.ui.common.ImagePicker
import app.tourism.ui.common.VerticalSpace
import app.tourism.ui.common.buttons.PrimaryButton
import app.tourism.ui.common.special.RatingBar
import app.tourism.ui.common.textfields.AppEditText
import app.tourism.ui.screens.main.place_details.reviews.PostReviewViewModel
import app.tourism.ui.screens.main.place_details.reviews.UiEvent
import app.tourism.ui.theme.TextStyles
import app.tourism.ui.theme.getBorderColor
import app.tourism.ui.utils.showToast
import app.tourism.utils.FileUtils
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import kotlin.math.roundToInt
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PostReview(
placeId: Long,
modifier: Modifier = Modifier,
onPostReviewSuccess: () -> Unit,
postReviewVM: PostReviewViewModel = hiltViewModel(),
) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val context = LocalContext.current
val rating = postReviewVM.rating.collectAsState().value
val comment = postReviewVM.comment.collectAsState().value
val files = postReviewVM.files.collectAsState().value
val postReviewResponse = postReviewVM.postReviewResponse.collectAsState().value
ObserveAsEvents(flow = postReviewVM.uiEventsChannelFlow) { event ->
when (event) {
UiEvent.CloseReviewBottomSheet -> onPostReviewSuccess()
is UiEvent.ShowToast -> context.showToast(event.message)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@ -74,7 +92,10 @@ fun PostReview(
) {
Text(text = stringResource(id = R.string.tap_to_rate), style = TextStyles.b3)
VerticalSpace(height = 4.dp)
RatingBar(rating = rating, onRatingChanged = { postReviewVM.setRating(it) })
RatingBar(
rating = rating.roundToInt(),
onRatingChanged = { postReviewVM.setRating(it) },
)
}
VerticalSpace(height = 16.dp)
@ -105,6 +126,7 @@ fun PostReview(
File(FileUtils(context).getPath(uri))
)
}
focusManager.clearFocus()
}
) {
AddPhoto()
@ -114,7 +136,8 @@ fun PostReview(
PrimaryButton(
label = stringResource(id = R.string.send),
onClick = { postReviewVM.postReview() },
onClick = { postReviewVM.postReview(placeId) },
isLoading = postReviewResponse is Resource.Loading
)
VerticalSpace(height = 64.dp)
}

View file

@ -55,7 +55,8 @@ import app.tourism.ui.theme.getHintColor
fun Review(
modifier: Modifier = Modifier,
review: Review,
onMoreClick: (picsUrls: List<String>) -> Unit
onMoreClick: (picsUrls: List<String>) -> Unit,
onDeleteClick: ((reviewId: Long) -> Unit)? = null,
) {
Column {
HorizontalDivider(color = MaterialTheme.colorScheme.surface)
@ -73,9 +74,9 @@ fun Review(
}
VerticalSpace(height = 16.dp)
review.rating?.let {
review.rating.let {
RatingBar(
rating = it.toFloat(),
rating = it,
size = 24.dp,
)
VerticalSpace(height = 16.dp)
@ -128,9 +129,14 @@ fun User(modifier: Modifier = Modifier, user: User) {
)
HorizontalSpace(width = 8.dp)
Column {
Text(text = user.name, style = TextStyles.h4, fontWeight = FontWeight.W600)
user.countryCodeName?.let {
Text(
text = user.name,
style = TextStyles.h4,
fontWeight = FontWeight.W600,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
user.countryCodeName.let {
CountryAsLabel(
Modifier.fillMaxWidth(),
user.countryCodeName,

View file

@ -254,6 +254,7 @@ fun ThemeSwitch(modifier: Modifier = Modifier, themeVM: ThemeViewModel) {
onCheckedChange = { isDark ->
val themeCode = if (isDark) "dark" else "light"
themeVM.setTheme(themeCode)
themeVM.updateThemeOnServer(themeCode)
},
colors = SwitchDefaults.colors(uncheckedTrackColor = MaterialTheme.colorScheme.background)
)

View file

@ -1,4 +1,4 @@
package app.tourism.ui.screens.main.home.search
package app.tourism.ui.screens.main.search
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
@ -30,6 +30,7 @@ import app.tourism.ui.theme.TextStyles
@Composable
fun SearchScreen(
onPlaceClick: (id: Long) -> Unit,
onBackClick: () -> Unit,
onMapClick: () -> Unit,
queryArg: String,
searchVM: SearchViewModel = hiltViewModel()
@ -53,6 +54,7 @@ fun SearchScreen(
onClick = onMapClick
),
),
onBackClick = onBackClick
)
},
contentWindowInsets = Constants.USUAL_WINDOW_INSETS
@ -66,7 +68,6 @@ fun SearchScreen(
modifier = Modifier.fillMaxWidth(),
query = query,
onQueryChanged = { searchVM.setQuery(it) },
onSearchClicked = { searchVM.search(it) },
onClearClicked = { searchVM.clearSearchField() },
)
VerticalSpace(height = 16.dp)

View file

@ -1,18 +1,23 @@
package app.tourism.ui.screens.main.home.search
package app.tourism.ui.screens.main.search
import androidx.lifecycle.ViewModel
import app.tourism.Constants
import androidx.lifecycle.viewModelScope
import app.tourism.data.repositories.PlacesRepository
import app.tourism.domain.models.common.PlaceShort
import app.tourism.domain.models.resource.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
private val placesRepository: PlacesRepository
) : ViewModel() {
private val uiChannel = Channel<UiEvent>()
val uiEventsChannelFlow = uiChannel.receiveAsFlow()
@ -25,10 +30,6 @@ class SearchViewModel @Inject constructor(
_query.value = value
}
fun search(value: String) {
// todo
}
fun clearSearchField() {
_query.value = ""
}
@ -41,24 +42,25 @@ class SearchViewModel @Inject constructor(
val itemsNumber = _itemsNumber.asStateFlow()
fun setFavoriteChanged(item: PlaceShort, isFavorite: Boolean) {
// todo
viewModelScope.launch(Dispatchers.IO) {
placesRepository.setFavorite(item.id, isFavorite)
}
}
private fun observeSearch() {
viewModelScope.launch(Dispatchers.IO) {
_query.collectLatest {
placesRepository.search(it).collectLatest { resource ->
if (resource is Resource.Success) {
resource.data?.let { _places.value = it }
}
}
}
}
}
init {
// todo replace with real data
val dummyData = mutableListOf<PlaceShort>()
repeat(15) {
dummyData.add(
PlaceShort(
id = it.toLong(),
name = "Гиссарская крепость",
pic = Constants.IMAGE_URL_EXAMPLE,
rating = 5.0,
excerpt = "завтрак включен, бассейн, сауна, с видом на озеро"
)
)
}
_places.update { dummyData }
observeSearch()
}
}

View file

@ -0,0 +1,3 @@
package app.tourism.utils
fun String?.isNotAbsent() = !this.isNullOrBlank()

View file

@ -2208,7 +2208,7 @@
<string name="chose_language">Выберите язык</string>
<string name="retry">Попробовать заново</string>
<string name="no_network">Не удается соединиться с сервером, проверьте интернет подключение</string>
<string name="no_image">Нет изображения</string>
<string name="no_image">Нет фото</string>
<string name="tjk">Таджикистан</string>
<string name="clear_search_field">Очистить поле поиска</string>
<string name="top30">Топ-30 мест</string>
@ -2220,4 +2220,5 @@
<string name="passwords_not_same">Пароли не схожи</string>
<string name="wrong_email_format">Неправильный формат имейла</string>
<string name="saved">Сохранено</string>
<string name="great_success">Мне нраится😄</string>
</resources>

View file

@ -2261,4 +2261,5 @@
<string name="passwords_not_same">Passwords are not the same</string>
<string name="wrong_email_format">Wrong email format</string>
<string name="saved">Saved</string>
<string name="great_success">Great success😄</string>
</resources>