← Назад к вопросам

Расписание тренировок фитнес-клуба

2.2 Middle🔥 111 комментариев
#UI и вёрстка#Архитектура и паттерны#Сетевое взаимодействие

Условие

Создать приложение для отображения расписания тренировок фитнес-клуба.

Функциональность:

  1. Список тренировок на выбранную дату
  2. Сортировка по дате и времени
  3. Информация о тренировке:
    • Название тренировки
    • Время начала и окончания
    • Имя тренера
    • Зал/студия
  4. Фильтрация по типу тренировки
  5. Возможность записаться на тренировку

Технические требования:

  • 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 поддержкой.

Расписание тренировок фитнес-клуба | PrepBro