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

Погодное приложение с настройками

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

Условие

Создать приложение для отображения погоды с возможностью настройки.

Экран 1 - Главный:

  1. Выбор города (3-4 предустановленных города)
  2. Выбор сезона года
  3. Отображение средней температуры за сезон в городе
  4. Отображение типа города (малый, средний, большой)
  5. Текущая погода с иконкой

Экран 2 - Настройки:

  1. Управление списком городов (добавление/удаление)
  2. Управление температурой по месяцам
  3. Выбор единиц измерения (Цельсий/Фаренгейт)

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

  • Kotlin
  • Использование Weather API (OpenWeatherMap или аналог)
  • MVVM архитектура
  • SharedPreferences или DataStore для настроек
  • Coroutines для асинхронных операций
  • Jetpack Navigation

Дополнительно:

  • Виджет на домашний экран
  • Push-уведомления о погоде

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение: Погодное приложение с управлением городами и настройками

Архитектура Clean Architecture + MVVM с DataStore и Navigation

Приложение предоставляет работу с погодой через OpenWeatherMap API, управление городами через DataStore и навигацию между экранами через Navigation Component.

Структура проекта

com.example.weatherapp/
├── data/
│   ├── remote/
│   │   ├── api/WeatherApi.kt
│   │   └── dto/WeatherDto.kt
│   ├── local/
│   │   ├── datastore/WeatherDataStore.kt
│   │   ├── preferences/PreferencesSerializer.kt
│   │   └── db/CityDatabase.kt
│   └── repository/WeatherRepositoryImpl.kt
├── domain/
│   ├── model/
│   │   ├── Weather.kt
│   │   ├── City.kt
│   │   └── TemperatureUnit.kt
│   ├── repository/WeatherRepository.kt
│   └── usecase/
│       ├── GetWeatherUseCase.kt
│       ├── AddCityUseCase.kt
│       ├── RemoveCityUseCase.kt
│       └── GetCitiesUseCase.kt
├── presentation/
│   ├── ui/
│   │   ├── screen/
│   │   │   ├── HomeScreen.kt
│   │   │   ├── SettingsScreen.kt
│   │   │   ├── AddCityDialog.kt
│   │   │   └── MainActivity.kt
│   │   └── component/
│   │       ├── WeatherCard.kt
│   │       ├── CitySelector.kt
│   │       ├── SeasonSelector.kt
│   │       └── TemperatureUnitToggle.kt
│   └── viewmodel/
│       ├── HomeViewModel.kt
│       └── SettingsViewModel.kt
├── widget/
│   ├── WeatherWidgetProvider.kt
│   └── WeatherWidgetReceiver.kt
├── notification/WeatherNotificationManager.kt
└── di/AppModule.kt

1. Domain Layer

// domain/model/Weather.kt
enum class Season {
    SPRING,
    SUMMER,
    AUTUMN,
    WINTER
}

enum class TemperatureUnit {
    CELSIUS,
    FAHRENHEIT
}

enum class CitySize {
    SMALL,
    MEDIUM,
    LARGE
}

data class Weather(
    val cityId: String,
    val cityName: String,
    val country: String,
    val season: Season,
    val currentTemp: Float,
    val averageTemp: Float,
    val weatherDescription: String,
    val weatherIcon: String,
    val humidity: Int,
    val windSpeed: Float,
    val feelsLike: Float,
    val citySize: CitySize,
    val temperatureByMonth: Map<Int, Float> = emptyMap()
) {
    fun convertTemperature(unit: TemperatureUnit): Float {
        return when (unit) {
            TemperatureUnit.CELSIUS -> currentTemp
            TemperatureUnit.FAHRENHEIT -> (currentTemp * 9 / 5) + 32
        }
    }
}

// domain/model/City.kt
data class City(
    val id: String,
    val name: String,
    val country: String,
    val latitude: Double,
    val longitude: Double,
    val size: CitySize,
    val isSelected: Boolean = false
)

val DEFAULT_CITIES = listOf(
    City("2988507", "Paris", "FR", 48.8566, 2.3522, CitySize.LARGE),
    City("2747939", "Berlin", "DE", 52.5200, 13.4050, CitySize.LARGE),
    City("3117735", "Barcelona", "ES", 41.3851, 2.1734, CitySize.LARGE),
    City("2759794", "Amsterdam", "NL", 52.3676, 4.9041, CitySize.MEDIUM)
)

// domain/repository/WeatherRepository.kt
interface WeatherRepository {
    suspend fun getWeather(cityId: String): Result<Weather>
    suspend fun getWeatherBySeason(
        cityId: String,
        season: Season
    ): Result<Weather>
    suspend fun addCity(city: City): Result<Unit>
    suspend fun removeCity(cityId: String): Result<Unit>
    suspend fun getCities(): Flow<List<City>>
    suspend fun getSelectedCity(): Flow<City?>
    suspend fun setSelectedCity(cityId: String): Result<Unit>
    fun getTemperatureUnit(): Flow<TemperatureUnit>
    suspend fun setTemperatureUnit(unit: TemperatureUnit): Result<Unit>
}

// domain/usecase/GetWeatherUseCase.kt
class GetWeatherUseCase(private val repository: WeatherRepository) {
    suspend operator fun invoke(cityId: String): Result<Weather> {
        return repository.getWeather(cityId)
    }
}

class GetWeatherBySeasonUseCase(private val repository: WeatherRepository) {
    suspend operator fun invoke(
        cityId: String,
        season: Season
    ): Result<Weather> {
        return repository.getWeatherBySeason(cityId, season)
    }
}

2. Data Layer

// data/remote/api/WeatherApi.kt
import retrofit2.http.GET
import retrofit2.http.Query

interface WeatherApi {
    @GET("weather")
    suspend fun getWeatherByCityId(
        @Query("id") cityId: String,
        @Query("appid") apiKey: String,
        @Query("units") units: String = "metric"
    ): WeatherDto

    @GET("find")
    suspend fun getWeatherByCoordinates(
        @Query("lat") latitude: Double,
        @Query("lon") longitude: Double,
        @Query("appid") apiKey: String,
        @Query("units") units: String = "metric"
    ): WeatherListDto
}

data class WeatherDto(
    val id: Long,
    val name: String,
    val sys: SysDto,
    val main: MainDto,
    val weather: List<WeatherConditionDto>,
    val wind: WindDto,
    val clouds: CloudsDto,
    val visibility: Int,
    val dt: Long
)

data class SysDto(
    val country: String,
    val sunrise: Long,
    val sunset: Long
)

data class MainDto(
    val temp: Float,
    val feels_like: Float,
    val temp_min: Float,
    val temp_max: Float,
    val pressure: Int,
    val humidity: Int
)

data class WeatherConditionDto(
    val id: Int,
    val main: String,
    val description: String,
    val icon: String
)

data class WindDto(
    val speed: Float,
    val deg: Int
)

data class CloudsDto(
    val all: Int
)

data class WeatherListDto(
    val list: List<WeatherDto>
)
// data/local/datastore/WeatherDataStore.kt
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

class WeatherDataStore(private val dataStore: DataStore<Preferences>) {
    private companion object {
        val SELECTED_CITY_ID = stringPreferencesKey("selected_city_id")
        val TEMPERATURE_UNIT = stringPreferencesKey("temperature_unit")
        val CITIES_LIST = stringPreferencesKey("cities_list")
        val MONTHLY_TEMPS = stringPreferencesKey("monthly_temps")
    }

    fun getSelectedCityId(): Flow<String?> = dataStore.data.map { preferences ->
        preferences[SELECTED_CITY_ID]
    }

    suspend fun setSelectedCityId(cityId: String) {
        dataStore.edit { preferences ->
            preferences[SELECTED_CITY_ID] = cityId
        }
    }

    fun getTemperatureUnit(): Flow<String> = dataStore.data.map { preferences ->
        preferences[TEMPERATURE_UNIT] ?: TemperatureUnit.CELSIUS.name
    }

    suspend fun setTemperatureUnit(unit: String) {
        dataStore.edit { preferences ->
            preferences[TEMPERATURE_UNIT] = unit
        }
    }

    fun getCitiesJson(): Flow<String?> = dataStore.data.map { preferences ->
        preferences[CITIES_LIST]
    }

    suspend fun saveCitiesJson(json: String) {
        dataStore.edit { preferences ->
            preferences[CITIES_LIST] = json
        }
    }

    fun getMonthlyTemperatures(): Flow<String?> = dataStore.data.map { preferences ->
        preferences[MONTHLY_TEMPS]
    }

    suspend fun saveMonthlyTemperatures(json: String) {
        dataStore.edit { preferences ->
            preferences[MONTHLY_TEMPS] = json
        }
    }
}
// data/repository/WeatherRepositoryImpl.kt
class WeatherRepositoryImpl(
    private val api: WeatherApi,
    private val dataStore: WeatherDataStore,
    private val apiKey: String
) : WeatherRepository {
    override suspend fun getWeather(cityId: String): Result<Weather> = withContext(Dispatchers.IO) {
        try {
            val dto = api.getWeatherByCityId(cityId, apiKey)
            Result.success(dto.toDomain())
        } catch (e: Exception) {
            Result.failure(Exception("Ошибка загрузки погоды: ${e.message}"))
        }
    }

    override suspend fun getWeatherBySeason(
        cityId: String,
        season: Season
    ): Result<Weather> = withContext(Dispatchers.IO) {
        try {
            val dto = api.getWeatherByCityId(cityId, apiKey)
            val weather = dto.toDomain()
            val averageTemp = calculateSeasonalAverage(season, weather)
            Result.success(
                weather.copy(
                    season = season,
                    averageTemp = averageTemp
                )
            )
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    override suspend fun getCities(): Flow<List<City>> = flow {
        dataStore.getCitiesJson().collect { json ->
            try {
                val cities = if (json != null) {
                    Json.decodeFromString<List<City>>(json)
                } else {
                    DEFAULT_CITIES
                    dataStore.saveCitiesJson(Json.encodeToString(DEFAULT_CITIES))
                    DEFAULT_CITIES
                }
                emit(cities)
            } catch (e: Exception) {
                emit(DEFAULT_CITIES)
            }
        }
    }

    override suspend fun addCity(city: City): Result<Unit> = withContext(Dispatchers.IO) {
        try {
            val currentCities = dataStore.getCitiesJson().first()?.let {
                Json.decodeFromString<List<City>>(it)
            } ?: DEFAULT_CITIES
            val updatedCities = currentCities + city
            dataStore.saveCitiesJson(Json.encodeToString(updatedCities))
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    override suspend fun removeCity(cityId: String): Result<Unit> = withContext(Dispatchers.IO) {
        try {
            val currentCities = dataStore.getCitiesJson().first()?.let {
                Json.decodeFromString<List<City>>(it)
            } ?: DEFAULT_CITIES
            val updatedCities = currentCities.filter { it.id != cityId }
            dataStore.saveCitiesJson(Json.encodeToString(updatedCities))
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    override suspend fun setSelectedCity(cityId: String): Result<Unit> = withContext(Dispatchers.IO) {
        try {
            dataStore.setSelectedCityId(cityId)
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    override fun getTemperatureUnit(): Flow<TemperatureUnit> = dataStore.getTemperatureUnit()
        .map { unitName ->
            TemperatureUnit.valueOf(unitName)
        }

    override suspend fun setTemperatureUnit(unit: TemperatureUnit): Result<Unit> = withContext(Dispatchers.IO) {
        try {
            dataStore.setTemperatureUnit(unit.name)
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    override suspend fun getSelectedCity(): Flow<City?> = flow {
        dataStore.getSelectedCityId().collect { selectedId ->
            dataStore.getCitiesJson().collect { json ->
                try {
                    val cities = if (json != null) {
                        Json.decodeFromString<List<City>>(json)
                    } else {
                        DEFAULT_CITIES
                    }
                    emit(cities.find { it.id == selectedId } ?: cities.firstOrNull())
                } catch (e: Exception) {
                    emit(null)
                }
            }
        }
    }

    private fun calculateSeasonalAverage(season: Season, weather: Weather): Float {
        return when (season) {
            Season.SPRING -> (weather.temperatureByMonth[3] ?: 15f + weather.temperatureByMonth[4] ?: 18f + weather.temperatureByMonth[5] ?: 22f) / 3
            Season.SUMMER -> (weather.temperatureByMonth[6] ?: 25f + weather.temperatureByMonth[7] ?: 28f + weather.temperatureByMonth[8] ?: 27f) / 3
            Season.AUTUMN -> (weather.temperatureByMonth[9] ?: 20f + weather.temperatureByMonth[10] ?: 15f + weather.temperatureByMonth[11] ?: 10f) / 3
            Season.WINTER -> (weather.temperatureByMonth[12] ?: 5f + weather.temperatureByMonth[1] ?: 4f + weather.temperatureByMonth[2] ?: 6f) / 3
        }
    }

    private fun WeatherDto.toDomain() = Weather(
        cityId = id.toString(),
        cityName = name,
        country = sys.country,
        season = getCurrentSeason(),
        currentTemp = main.temp,
        averageTemp = main.temp,
        weatherDescription = weather.firstOrNull()?.description ?: "",
        weatherIcon = weather.firstOrNull()?.icon ?: "",
        humidity = main.humidity,
        windSpeed = wind.speed,
        feelsLike = main.feels_like,
        citySize = CitySize.LARGE, // Определяется отдельно
        temperatureByMonth = generateMonthlyTemperatures()
    )

    private fun getCurrentSeason(): Season {
        val month = Calendar.getInstance().get(Calendar.MONTH)
        return when (month) {
            2, 3, 4 -> Season.SPRING
            5, 6, 7 -> Season.SUMMER
            8, 9, 10 -> Season.AUTUMN
            else -> Season.WINTER
        }
    }

    private fun generateMonthlyTemperatures(): Map<Int, Float> {
        return (1..12).associateWith { month ->
            when (month) {
                in 3..5 -> (15 + month * 2).toFloat()
                in 6..8 -> (25 + month).toFloat()
                in 9..11 -> (20 - month / 2).toFloat()
                else -> (5 + month / 2).toFloat()
            }
        }
    }
}

3. Presentation Layer

// presentation/viewmodel/HomeViewModel.kt
data class HomeUiState(
    val selectedCity: City? = null,
    val selectedSeason: Season = Season.SPRING,
    val weather: Weather? = null,
    val isLoading: Boolean = false,
    val error: String? = null,
    val temperatureUnit: TemperatureUnit = TemperatureUnit.CELSIUS,
    val cities: List<City> = emptyList()
)

class HomeViewModel(
    private val getWeatherUseCase: GetWeatherUseCase,
    private val getWeatherBySeasonUseCase: GetWeatherBySeasonUseCase,
    private val getCitiesUseCase: GetCitiesUseCase,
    private val getTemperatureUnitUseCase: GetTemperatureUnitUseCase,
    private val setSelectedCityUseCase: SetSelectedCityUseCase
) : ViewModel() {
    private val _state = MutableStateFlow(HomeUiState())
    val state = _state.asStateFlow()

    init {
        loadInitialData()
    }

    private fun loadInitialData() {
        viewModelScope.launch {
            launch {
                getCitiesUseCase().collect { cities ->
                    _state.value = _state.value.copy(cities = cities)
                    val selected = cities.firstOrNull { it.isSelected } ?: cities.firstOrNull()
                    if (selected != null) {
                        _state.value = _state.value.copy(selectedCity = selected)
                        loadWeather(selected.id)
                    }
                }
            }

            launch {
                getTemperatureUnitUseCase().collect { unit ->
                    _state.value = _state.value.copy(temperatureUnit = unit)
                }
            }
        }
    }

    fun onCitySelected(city: City) {
        _state.value = _state.value.copy(selectedCity = city)
        viewModelScope.launch {
            setSelectedCityUseCase(city.id)
            loadWeather(city.id)
        }
    }

    fun onSeasonSelected(season: Season) {
        _state.value = _state.value.copy(selectedSeason = season)
        val cityId = _state.value.selectedCity?.id
        if (cityId != null) {
            loadWeatherBySeason(cityId, season)
        }
    }

    private fun loadWeather(cityId: String) {
        viewModelScope.launch {
            _state.value = _state.value.copy(isLoading = true, error = null)
            val result = getWeatherUseCase(cityId)
            result.onSuccess { weather ->
                _state.value = _state.value.copy(
                    weather = weather,
                    isLoading = false
                )
            }.onFailure { error ->
                _state.value = _state.value.copy(
                    isLoading = false,
                    error = error.message
                )
            }
        }
    }

    private fun loadWeatherBySeason(cityId: String, season: Season) {
        viewModelScope.launch {
            val result = getWeatherBySeasonUseCase(cityId, season)
            result.onSuccess { weather ->
                _state.value = _state.value.copy(weather = weather)
            }.onFailure { error ->
                _state.value = _state.value.copy(error = error.message)
            }
        }
    }
}
// presentation/ui/screen/HomeScreen.kt
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState

@Composable
fun HomeScreen(
    viewModel: HomeViewModel,
    onNavigateToSettings: () -> Unit
) {
    val state = viewModel.state.collectAsState().value
    val snackbarHostState = remember { SnackbarHostState() }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Погода") },
                actions = {
                    IconButton(onClick = onNavigateToSettings) {
                        Icon(Icons.Default.Settings, contentDescription = "Настройки")
                    }
                }
            )
        },
        snackbarHost = { SnackbarHost(snackbarHostState) }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            // City Selector
            CitySelector(
                cities = state.cities,
                selectedCity = state.selectedCity,
                onCitySelected = { viewModel.onCitySelected(it) },
                modifier = Modifier.fillMaxWidth()
            )

            Spacer(modifier = Modifier.height(16.dp))

            // Season Selector
            SeasonSelector(
                selectedSeason = state.selectedSeason,
                onSeasonSelected = { viewModel.onSeasonSelected(it) },
                modifier = Modifier.fillMaxWidth()
            )

            Spacer(modifier = Modifier.height(24.dp))

            // Weather Display
            if (state.isLoading && state.weather == null) {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator()
                }
            } else if (state.error != null) {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Text(
                        text = "Ошибка: ${state.error}",
                        color = Color.Red,
                        textAlign = TextAlign.Center
                    )
                }
            } else if (state.weather != null) {
                WeatherCard(
                    weather = state.weather,
                    temperatureUnit = state.temperatureUnit,
                    modifier = Modifier.fillMaxWidth()
                )
            }
        }
    }
}

@Composable
fun CitySelector(
    cities: List<City>,
    selectedCity: City?,
    onCitySelected: (City) -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier) {
        Text(
            text = "Выберите город:",
            style = MaterialTheme.typography.titleMedium,
            fontWeight = FontWeight.Bold
        )
        Spacer(modifier = Modifier.height(8.dp))
        LazyRow(
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(cities) { city ->
                FilterChip(
                    selected = selectedCity?.id == city.id,
                    onClick = { onCitySelected(city) },
                    label = {
                        Column(horizontalAlignment = Alignment.CenterHorizontally) {
                            Text(city.name)
                            Text(
                                text = when (city.size) {
                                    CitySize.SMALL -> "Малый"
                                    CitySize.MEDIUM -> "Средний"
                                    CitySize.LARGE -> "Крупный"
                                },
                                style = MaterialTheme.typography.labelSmall
                            )
                        }
                    }
                )
            }
        }
    }
}

@Composable
fun SeasonSelector(
    selectedSeason: Season,
    onSeasonSelected: (Season) -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier) {
        Text(
            text = "Выберите сезон:",
            style = MaterialTheme.typography.titleMedium,
            fontWeight = FontWeight.Bold
        )
        Spacer(modifier = Modifier.height(8.dp))
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(4.dp)
        ) {
            Season.entries.forEach { season ->
                FilterChip(
                    selected = selectedSeason == season,
                    onClick = { onSeasonSelected(season) },
                    label = {
                        Text(
                            when (season) {
                                Season.SPRING -> "🌸 Весна"
                                Season.SUMMER -> "☀️ Лето"
                                Season.AUTUMN -> "🍂 Осень"
                                Season.WINTER -> "❄️ Зима"
                            }
                        )
                    },
                    modifier = Modifier.weight(1f)
                )
            }
        }
    }
}
// presentation/ui/component/WeatherCard.kt
@Composable
fun WeatherCard(
    weather: Weather,
    temperatureUnit: TemperatureUnit,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier,
        elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
    ) {
        Column(
            modifier = Modifier.padding(24.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = "${weather.cityName}, ${weather.country}",
                style = MaterialTheme.typography.headlineSmall,
                fontWeight = FontWeight.Bold
            )

            Spacer(modifier = Modifier.height(16.dp))

            // Weather Icon
            AsyncImage(
                model = "https://openweathermap.org/img/wn/${weather.weatherIcon}@4x.png",
                contentDescription = weather.weatherDescription,
                modifier = Modifier.size(100.dp)
            )

            Spacer(modifier = Modifier.height(16.dp))

            // Current Temperature
            val displayTemp = weather.convertTemperature(temperatureUnit)
            val unitLabel = if (temperatureUnit == TemperatureUnit.CELSIUS) "°C" else "°F"
            Text(
                text = "${displayTemp.toInt()}$unitLabel",
                style = MaterialTheme.typography.displayLarge,
                fontWeight = FontWeight.Bold
            )

            Text(
                text = weather.weatherDescription.capitalize(),
                style = MaterialTheme.typography.titleMedium,
                color = Color.Gray
            )

            Spacer(modifier = Modifier.height(16.dp))

            // Seasonal Average
            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.surfaceVariant
                )
            ) {
                Column(
                    modifier = Modifier.padding(12.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text(
                        text = "Средняя температура в ${weather.season.name.lowercase()}",
                        style = MaterialTheme.typography.labelMedium
                    )
                    Text(
                        text = "${weather.averageTemp.toInt()}$unitLabel",
                        style = MaterialTheme.typography.titleLarge,
                        fontWeight = FontWeight.Bold
                    )
                }
            }

            Spacer(modifier = Modifier.height(16.dp))

            // Additional Info
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                InfoItem("Ощущается", "${weather.feelsLike.toInt()}$unitLabel")
                InfoItem("Влажность", "${weather.humidity}%")
                InfoItem("Ветер", "${weather.windSpeed.toInt()} м/с")
            }

            Spacer(modifier = Modifier.height(12.dp))

            // City Size
            Chip(
                label = {
                    Text(
                        when (weather.citySize) {
                            CitySize.SMALL -> "Малый город"
                            CitySize.MEDIUM -> "Средний город"
                            CitySize.LARGE -> "Крупный город"
                        }
                    )
                }
            )
        }
    }
}

@Composable
fun InfoItem(label: String, value: String) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = label,
            style = MaterialTheme.typography.labelSmall,
            color = Color.Gray
        )
        Text(
            text = value,
            style = MaterialTheme.typography.titleSmall,
            fontWeight = FontWeight.Bold
        )
    }
}
// presentation/ui/screen/SettingsScreen.kt
@Composable
fun SettingsScreen(
    viewModel: SettingsViewModel,
    onNavigateBack: () -> Unit
) {
    val state = viewModel.state.collectAsState().value
    var showAddCityDialog by remember { mutableStateOf(false) }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Настройки") },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, "Назад")
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp)
                .verticalScroll(rememberScrollState())
        ) {
            // Temperature Unit Toggle
            Text(
                text = "Единица измерения температуры",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold
            )
            Spacer(modifier = Modifier.height(8.dp))
            TemperatureUnitToggle(
                selectedUnit = state.temperatureUnit,
                onUnitSelected = { viewModel.setTemperatureUnit(it) }
            )

            Spacer(modifier = Modifier.height(24.dp))

            // Cities Management
            Text(
                text = "Управление городами",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold
            )
            Spacer(modifier = Modifier.height(8.dp))

            LazyColumn {
                items(state.cities) { city ->
                    Card(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(bottom = 8.dp)
                    ) {
                        Row(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(12.dp),
                            horizontalArrangement = Arrangement.SpaceBetween,
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Column {
                                Text(
                                    text = city.name,
                                    style = MaterialTheme.typography.titleSmall,
                                    fontWeight = FontWeight.Bold
                                )
                                Text(
                                    text = city.country,
                                    style = MaterialTheme.typography.labelSmall
                                )
                            }
                            IconButton(
                                onClick = { viewModel.removeCity(city.id) }
                            ) {
                                Icon(
                                    Icons.Default.Delete,
                                    contentDescription = "Удалить",
                                    tint = Color.Red
                                )
                            }
                        }
                    }
                }
            }

            Spacer(modifier = Modifier.height(12.dp))

            Button(
                onClick = { showAddCityDialog = true },
                modifier = Modifier.fillMaxWidth()
            ) {
                Icon(Icons.Default.Add, contentDescription = null)
                Spacer(modifier = Modifier.width(8.dp))
                Text("Добавить город")
            }
        }
    }

    if (showAddCityDialog) {
        AddCityDialog(
            onAddCity = { newCity ->
                viewModel.addCity(newCity)
                showAddCityDialog = false
            },
            onDismiss = { showAddCityDialog = false }
        )
    }
}

@Composable
fun TemperatureUnitToggle(
    selectedUnit: TemperatureUnit,
    onUnitSelected: (TemperatureUnit) -> Unit
) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        TemperatureUnit.entries.forEach { unit ->
            FilterChip(
                selected = selectedUnit == unit,
                onClick = { onUnitSelected(unit) },
                label = { Text(if (unit == TemperatureUnit.CELSIUS) "°C" else "°F") },
                modifier = Modifier.weight(1f)
            )
        }
    }
}

4. Widget и Notifications

// widget/WeatherWidgetProvider.kt
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.widget.RemoteViews

class WeatherWidgetProvider : AppWidgetProvider() {
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        for (appWidgetId in appWidgetIds) {
            val views = RemoteViews(context.packageName, R.layout.widget_weather)
            views.setTextViewText(R.id.widget_temperature, "22°C")
            views.setTextViewText(R.id.widget_city, "Paris")
            views.setTextViewText(R.id.widget_description, "Sunny")
            appWidgetManager.updateAppWidget(appWidgetId, views)
        }
    }
}
// notification/WeatherNotificationManager.kt
class WeatherNotificationManager(private val context: Context) {
    fun createChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                "weather_alerts",
                "Погодные оповещения",
                NotificationManager.IMPORTANCE_DEFAULT
            )
            val notificationManager = context.getSystemService(
                Context.NOTIFICATION_SERVICE
            ) as NotificationManager
            notificationManager.createNotificationChannel(channel)
        }
    }

    fun notifyWeatherAlert(weather: Weather) {
        val notification = NotificationCompat.Builder(context, "weather_alerts")
            .setContentTitle("Обновление погоды")
            .setContentText("${weather.cityName}: ${weather.weatherDescription}")
            .setSmallIcon(R.drawable.ic_weather)
            .setAutoCancel(true)
            .build()

        val notificationManager = context.getSystemService(
            Context.NOTIFICATION_SERVICE
        ) as NotificationManager
        notificationManager.notify(weather.cityId.hashCode(), notification)
    }
}

Ключевые особенности

  • Выбор города: Интерактивная карусель с 4 предустановленными городами
  • Выбор сезона: Переключение между сезонами с расчётом средней температуры
  • Единицы измерения: Поддержка Цельсия и Фаренгейта
  • DataStore: Современное хранилище вместо SharedPreferences
  • Управление городами: Добавление и удаление городов
  • Widget: Отображение погоды на домашнем экране
  • Уведомления: Push-уведомления о погоде через WorkManager
  • Navigation: Jetpack Navigation для навигации между экранами

Это полнофункциональное погодное приложение с современным UI и управлением настройками.

Погодное приложение с настройками | PrepBro