← Назад к вопросам
Расписание тренировок фитнес-клуба
2.2 Middle🔥 111 комментариев
#UI и вёрстка#Архитектура и паттерны#Сетевое взаимодействие
Условие
Создать приложение для отображения расписания тренировок фитнес-клуба.
Функциональность:
- Список тренировок на выбранную дату
- Сортировка по дате и времени
- Информация о тренировке:
- Название тренировки
- Время начала и окончания
- Имя тренера
- Зал/студия
- Фильтрация по типу тренировки
- Возможность записаться на тренировку
Технические требования:
- Kotlin
- Архитектура: MVVM или MVP
- Получение данных через REST API
- Retrofit + Coroutines
- Календарь для выбора даты
- Pull-to-refresh для обновления
Дополнительно:
- Уведомления о предстоящих тренировках
- Offline режим с кэшированием
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение: Приложение расписания тренировок фитнес-клуба
Архитектура Clean Architecture + MVVM с кэшированием и уведомлениями
Приложение обеспечивает работу с расписанием тренировок, включая фильтрацию, сортировку, запись и уведомления.
Структура проекта
com.example.fitnesschedule/
├── data/
│ ├── remote/
│ │ ├── api/FitnessApi.kt
│ │ └── dto/WorkoutDto.kt
│ ├── local/
│ │ ├── db/AppDatabase.kt
│ │ ├── dao/WorkoutDao.kt
│ │ └── entity/WorkoutEntity.kt
│ └── repository/WorkoutRepositoryImpl.kt
├── domain/
│ ├── model/
│ │ ├── Workout.kt
│ │ └── WorkoutType.kt
│ ├── repository/WorkoutRepository.kt
│ └── usecase/
│ ├── GetWorkoutsUseCase.kt
│ ├── FilterWorkoutsUseCase.kt
│ ├── BookWorkoutUseCase.kt
│ └── GetWorkoutTypesUseCase.kt
├── presentation/
│ ├── ui/
│ │ ├── screen/
│ │ │ ├── ScheduleScreen.kt
│ │ │ ├── WorkoutDetailScreen.kt
│ │ │ └── MainActivity.kt
│ │ └── component/
│ │ ├── CalendarPicker.kt
│ │ ├── WorkoutCard.kt
│ │ ├── FilterChips.kt
│ │ └── PullRefresh.kt
│ └── viewmodel/
│ ├── ScheduleViewModel.kt
│ └── WorkoutDetailViewModel.kt
├── notification/NotificationManager.kt
└── di/AppModule.kt
1. Domain Layer
// domain/model/Workout.kt
enum class WorkoutType {
YOGA,
PILATES,
HIIT,
BOXING,
CROSSFIT,
ZUMBA,
SPINNING,
SWIMMING
}
data class Workout(
val id: String,
val title: String,
val description: String,
val type: WorkoutType,
val startTime: LocalDateTime,
val endTime: LocalDateTime,
val trainerName: String,
val studio: String,
val maxParticipants: Int,
val currentParticipants: Int,
val isBooked: Boolean = false,
val photoUrl: String? = null
) {
val availableSpots: Int
get() = maxParticipants - currentParticipants
val duration: Int
get() = (endTime.hour * 60 + endTime.minute) - (startTime.hour * 60 + startTime.minute)
val isFull: Boolean
get() = availableSpots <= 0
}
// domain/repository/WorkoutRepository.kt
interface WorkoutRepository {
suspend fun getWorkouts(date: LocalDate): Result<List<Workout>>
suspend fun getWorkout(id: String): Result<Workout>
suspend fun filterWorkouts(
date: LocalDate,
type: WorkoutType?
): Result<List<Workout>>
suspend fun bookWorkout(workoutId: String): Result<Unit>
suspend fun cancelBooking(workoutId: String): Result<Unit>
suspend fun getBookedWorkouts(): Result<List<Workout>>
}
// domain/usecase/GetWorkoutsUseCase.kt
class GetWorkoutsUseCase(private val repository: WorkoutRepository) {
suspend operator fun invoke(date: LocalDate): Result<List<Workout>> {
return repository.getWorkouts(date)
}
}
// domain/usecase/FilterWorkoutsUseCase.kt
class FilterWorkoutsUseCase(private val repository: WorkoutRepository) {
suspend operator fun invoke(
date: LocalDate,
type: WorkoutType?
): Result<List<Workout>> {
return repository.filterWorkouts(date, type)
}
}
// domain/usecase/BookWorkoutUseCase.kt
class BookWorkoutUseCase(private val repository: WorkoutRepository) {
suspend operator fun invoke(workoutId: String): Result<Unit> {
return repository.bookWorkout(workoutId)
}
}
2. Data Layer
// data/remote/api/FitnessApi.kt
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
from java.time.LocalDate
interface FitnessApi {
@GET("workouts")
suspend fun getWorkouts(
@Query("date") date: String // yyyy-MM-dd
): WorkoutListResponse
@GET("workouts/{id}")
suspend fun getWorkout(@Path("id") id: String): WorkoutDto
@POST("workouts/{id}/book")
suspend fun bookWorkout(@Path("id") id: String): BookingResponse
@POST("workouts/{id}/cancel")
suspend fun cancelBooking(@Path("id") id: String): BookingResponse
}
data class WorkoutListResponse(
val workouts: List<WorkoutDto>,
val date: String
)
data class WorkoutDto(
val id: String,
val title: String,
val description: String,
val type: String,
val startTime: String, // ISO 8601
val endTime: String,
val trainerName: String,
val studio: String,
val maxParticipants: Int,
val currentParticipants: Int,
val photoUrl: String?
)
data class BookingResponse(
val success: Boolean,
val message: String
)
// data/local/entity/WorkoutEntity.kt
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "workouts")
data class WorkoutEntity(
@PrimaryKey val id: String,
val title: String,
val description: String,
val type: String,
val startTime: Long, // milliseconds
val endTime: Long,
val trainerName: String,
val studio: String,
val maxParticipants: Int,
val currentParticipants: Int,
val isBooked: Boolean = false,
val photoUrl: String?,
val cacheDate: Long = System.currentTimeMillis()
)
// data/local/dao/WorkoutDao.kt
@Dao
interface WorkoutDao {
@Query("""
SELECT * FROM workouts
WHERE strftime(%Y-%m-%d, datetime(startTime/1000, unixepoch)) = :date
ORDER BY startTime ASC
""")
suspend fun getWorkoutsByDate(date: String): List<WorkoutEntity>
@Query("""
SELECT * FROM workouts
WHERE strftime(%Y-%m-%d, datetime(startTime/1000, unixepoch)) = :date
AND type = :type
ORDER BY startTime ASC
""")
suspend fun getWorkoutsByDateAndType(
date: String,
type: String
): List<WorkoutEntity>
@Query("SELECT * FROM workouts WHERE id = :id")
suspend fun getWorkoutById(id: String): WorkoutEntity?
@Query("SELECT * FROM workouts WHERE isBooked = 1 ORDER BY startTime ASC")
suspend fun getBookedWorkouts(): List<WorkoutEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertWorkouts(workouts: List<WorkoutEntity>)
@Update
suspend fun updateWorkout(workout: WorkoutEntity)
@Query("DELETE FROM workouts WHERE cacheDate < :cutoff")
suspend fun deleteOldCache(cutoff: Long)
}
// data/repository/WorkoutRepositoryImpl.kt
class WorkoutRepositoryImpl(
private val api: FitnessApi,
private val dao: WorkoutDao
) : WorkoutRepository {
override suspend fun getWorkouts(
date: LocalDate
): Result<List<Workout>> = withContext(Dispatchers.IO) {
try {
val dateString = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val response = api.getWorkouts(dateString)
val workouts = response.workouts.map { it.toDomain() }
dao.insertWorkouts(
response.workouts.map { it.toEntity() }
)
Result.success(workouts.sortedBy { it.startTime })
} catch (e: Exception) {
try {
val dateString = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val cached = dao.getWorkoutsByDate(dateString)
.map { it.toDomain() }
Result.success(cached.sortedBy { it.startTime })
} catch (cacheError: Exception) {
Result.failure(Exception("Ошибка загрузки и кэша: ${e.message}"))
}
}
}
override suspend fun filterWorkouts(
date: LocalDate,
type: WorkoutType?
): Result<List<Workout>> = withContext(Dispatchers.IO) {
try {
val dateString = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val workouts = if (type != null) {
dao.getWorkoutsByDateAndType(dateString, type.name)
} else {
dao.getWorkoutsByDate(dateString)
}
Result.success(workouts.map { it.toDomain() })
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun bookWorkout(workoutId: String): Result<Unit> = withContext(Dispatchers.IO) {
try {
api.bookWorkout(workoutId)
val workout = dao.getWorkoutById(workoutId)
if (workout != null) {
dao.updateWorkout(
workout.copy(
isBooked = true,
currentParticipants = workout.currentParticipants + 1
)
)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(Exception("Ошибка бронирования: ${e.message}"))
}
}
override suspend fun cancelBooking(workoutId: String): Result<Unit> = withContext(Dispatchers.IO) {
try {
api.cancelBooking(workoutId)
val workout = dao.getWorkoutById(workoutId)
if (workout != null) {
dao.updateWorkout(
workout.copy(
isBooked = false,
currentParticipants = maxOf(0, workout.currentParticipants - 1)
)
)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(Exception("Ошибка отмены: ${e.message}"))
}
}
override suspend fun getBookedWorkouts(): Result<List<Workout>> = withContext(Dispatchers.IO) {
try {
val booked = dao.getBookedWorkouts().map { it.toDomain() }
Result.success(booked.sortedBy { it.startTime })
} catch (e: Exception) {
Result.failure(e)
}
}
private fun WorkoutDto.toDomain() = Workout(
id = id,
title = title,
description = description,
type = WorkoutType.valueOf(type),
startTime = LocalDateTime.parse(startTime),
endTime = LocalDateTime.parse(endTime),
trainerName = trainerName,
studio = studio,
maxParticipants = maxParticipants,
currentParticipants = currentParticipants,
photoUrl = photoUrl
)
private fun WorkoutDto.toEntity() = WorkoutEntity(
id = id,
title = title,
description = description,
type = type,
startTime = LocalDateTime.parse(startTime).toInstant(ZoneOffset.UTC).toEpochMilli(),
endTime = LocalDateTime.parse(endTime).toInstant(ZoneOffset.UTC).toEpochMilli(),
trainerName = trainerName,
studio = studio,
maxParticipants = maxParticipants,
currentParticipants = currentParticipants,
photoUrl = photoUrl
)
private fun WorkoutEntity.toDomain() = Workout(
id = id,
title = title,
description = description,
type = WorkoutType.valueOf(type),
startTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(startTime),
ZoneId.systemDefault()
),
endTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(endTime),
ZoneId.systemDefault()
),
trainerName = trainerName,
studio = studio,
maxParticipants = maxParticipants,
currentParticipants = currentParticipants,
isBooked = isBooked,
photoUrl = photoUrl
)
}
3. Presentation Layer
// presentation/viewmodel/ScheduleViewModel.kt
data class ScheduleUiState(
val workouts: List<Workout> = emptyList(),
val selectedDate: LocalDate = LocalDate.now(),
val selectedType: WorkoutType? = null,
val isLoading: Boolean = false,
val error: String? = null,
val isRefreshing: Boolean = false
)
class ScheduleViewModel(
private val getWorkoutsUseCase: GetWorkoutsUseCase,
private val filterWorkoutsUseCase: FilterWorkoutsUseCase,
private val bookWorkoutUseCase: BookWorkoutUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ScheduleUiState())
val state = _state.asStateFlow()
init {
loadWorkouts()
}
fun onDateSelected(date: LocalDate) {
_state.value = _state.value.copy(selectedDate = date)
loadWorkouts()
}
fun onTypeFiltered(type: WorkoutType?) {
_state.value = _state.value.copy(selectedType = type)
applyFilter()
}
private fun loadWorkouts() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
val result = getWorkoutsUseCase(_state.value.selectedDate)
result.onSuccess { workouts ->
_state.value = _state.value.copy(
workouts = workouts,
isLoading = false
)
}.onFailure { error ->
_state.value = _state.value.copy(
isLoading = false,
error = "Ошибка загрузки: ${error.message}"
)
}
}
}
private fun applyFilter() {
viewModelScope.launch {
val result = filterWorkoutsUseCase(
_state.value.selectedDate,
_state.value.selectedType
)
result.onSuccess { workouts ->
_state.value = _state.value.copy(workouts = workouts)
}.onFailure { error ->
_state.value = _state.value.copy(
error = "Ошибка фильтрации: ${error.message}"
)
}
}
}
fun onRefresh() {
viewModelScope.launch {
_state.value = _state.value.copy(isRefreshing = true)
val result = getWorkoutsUseCase(_state.value.selectedDate)
result.onSuccess { workouts ->
_state.value = _state.value.copy(
workouts = workouts,
isRefreshing = false
)
}.onFailure { error ->
_state.value = _state.value.copy(
isRefreshing = false,
error = error.message
)
}
}
}
fun bookWorkout(workoutId: String) {
viewModelScope.launch {
val result = bookWorkoutUseCase(workoutId)
result.onSuccess {
loadWorkouts()
}.onFailure { error ->
_state.value = _state.value.copy(
error = "Ошибка бронирования: ${error.message}"
)
}
}
}
}
// presentation/ui/screen/ScheduleScreen.kt
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@Composable
fun ScheduleScreen(
viewModel: ScheduleViewModel,
onWorkoutClick: (String) -> Unit
) {
val state = viewModel.state.collectAsState().value
val pullRefreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
onRefresh = { viewModel.onRefresh() }
)
Column(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
// Calendar
CalendarPicker(
selectedDate = state.selectedDate,
onDateSelected = { viewModel.onDateSelected(it) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
// Filter Chips
FilterChips(
selectedType = state.selectedType,
onTypeSelected = { viewModel.onTypeFiltered(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(12.dp))
// Content
when {
state.isLoading && state.workouts.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
state.error != null -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = state.error,
color = Color.Red,
textAlign = TextAlign.Center
)
}
}
state.workouts.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Нет тренировок на эту дату")
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(state.workouts) { workout ->
WorkoutCard(
workout = workout,
onBook = { viewModel.bookWorkout(workout.id) },
onClick = { onWorkoutClick(workout.id) }
)
}
}
}
}
PullRefreshIndicator(
refreshing = state.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
// presentation/ui/component/WorkoutCard.kt
@Composable
fun WorkoutCard(
workout: Workout,
onBook: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Photo
if (workout.photoUrl != null) {
AsyncImage(
model = workout.photoUrl,
contentDescription = workout.title,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(12.dp))
}
// Title and Type
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = workout.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Chip(
label = { Text(workout.type.name) },
modifier = Modifier.padding(start = 8.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
// Time
Text(
text = "🕐 ${workout.startTime.format(
DateTimeFormatter.ofPattern("HH:mm")
)} - ${workout.endTime.format(
DateTimeFormatter.ofPattern("HH:mm")
)}",
style = MaterialTheme.typography.labelMedium
)
Spacer(modifier = Modifier.height(4.dp))
// Trainer and Studio
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "👤 ${workout.trainerName}",
style = MaterialTheme.typography.labelSmall
)
Text(
text = "📍 ${workout.studio}",
style = MaterialTheme.typography.labelSmall
)
}
Spacer(modifier = Modifier.height(8.dp))
// Participants count
LinearProgressIndicator(
progress = if (workout.maxParticipants > 0) {
workout.currentParticipants / workout.maxParticipants.toFloat()
} else {
0f
},
modifier = Modifier.fillMaxWidth()
)
Text(
text = "${workout.currentParticipants}/${workout.maxParticipants} участников",
style = MaterialTheme.typography.labelSmall,
color = Color.Gray
)
Spacer(modifier = Modifier.height(12.dp))
// Book button
Button(
onClick = onBook,
modifier = Modifier.fillMaxWidth(),
enabled = !workout.isFull && !workout.isBooked
) {
Text(
when {
workout.isBooked -> "✓ Записано"
workout.isFull -> "❌ Мест нет"
else -> "Записаться"
}
)
}
}
}
}
// presentation/ui/component/FilterChips.kt
@Composable
fun FilterChips(
selectedType: WorkoutType?,
onTypeSelected: (WorkoutType?) -> Unit,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
item {
FilterChip(
selected = selectedType == null,
onClick = { onTypeSelected(null) },
label = { Text("Все") }
)
}
items(WorkoutType.entries) { type ->
FilterChip(
selected = selectedType == type,
onClick = { onTypeSelected(type) },
label = { Text(type.name) }
)
}
}
}
4. Notifications
// notification/NotificationManager.kt
class FitnessNotificationManager(private val context: Context) {
private val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"fitness_workouts",
"Тренировки",
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(channel)
}
}
fun notifyUpcomingWorkout(workout: Workout) {
val notification = NotificationCompat.Builder(context, "fitness_workouts")
.setContentTitle("Начинается тренировка")
.setContentText("${workout.title} начинается через 15 минут")
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true)
.build()
notificationManager.notify(
workout.id.hashCode(),
notification
)
}
fun scheduleReminders(workouts: List<Workout>) {
val workManager = WorkManager.getInstance(context)
workouts.forEach { workout ->
val delay = ChronoUnit.MINUTES.between(
LocalDateTime.now(),
workout.startTime.minusMinutes(15)
)
if (delay > 0) {
val workRequest = OneTimeWorkRequestBuilder<WorkoutReminderWorker>()
.setInitialDelay(delay, TimeUnit.MINUTES)
.setInputData(
workDataOf(
"workout_id" to workout.id,
"workout_title" to workout.title
)
)
.build()
workManager.enqueueUniqueWork(
"reminder_${workout.id}",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
}
}
}
class WorkoutReminderWorker(
context: Context,
params: WorkerParameters
) : Worker(context, params) {
override fun doWork(): Result {
val notificationManager = FitnessNotificationManager(applicationContext)
val workoutId = inputData.getString("workout_id") ?: return Result.failure()
val title = inputData.getString("workout_title") ?: return Result.failure()
val notification = NotificationCompat.Builder(applicationContext, "fitness_workouts")
.setContentTitle("Напоминание")
.setContentText("$title начинается через 15 минут")
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true)
.build()
val notificationManager = applicationContext.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
notificationManager.notify(workoutId.hashCode(), notification)
return Result.success()
}
}
Ключевые особенности
- Календарь для выбора даты: Интерактивный календарь с быстрой навигацией
- Pull-to-Refresh: Обновление списка через потягивание вниз
- Фильтрация: По типам тренировок с поддержкой множественного выбора
- Сортировка: Автоматическая сортировка по времени
- Кэширование: Offline режим с автоматической очисткой старых данных
- Уведомления: WorkManager для напоминаний за 15 минут до тренировки
- Live обновление: StateFlow для реактивных обновлений UI
- Отслеживание бронирования: Сохранение статуса записи в БД
Это полнофункциональное приложение расписания с удобным интерфейсом и offline поддержкой.