From 1a7906a1d4916033b0e61dcb52bb607f0497065e Mon Sep 17 00:00:00 2001 From: Max7526 Date: Tue, 2 Dec 2025 11:03:17 -0800 Subject: [PATCH] Add Material3 style dependency --- .gitignore | 28 ++ MainActivity.kt | 217 --------- README.md | 15 + app/build.gradle.kts | 79 ++++ app/proguard-rules.pro | 3 + app/src/main/AndroidManifest.xml | 23 + .../main/java/com/moviebox/MainActivity.kt | 416 ++++++++++++++++++ .../main/java/com/moviebox/ui/theme/Color.kt | 61 +++ .../main/java/com/moviebox/ui/theme/Theme.kt | 94 ++++ .../main/java/com/moviebox/ui/theme/Type.kt | 5 + .../res/drawable/ic_launcher_foreground.xml | 9 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 10 + build.gradle.kts | 8 + gradle.properties | 5 + gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 18 + gradlew.bat | 9 + settings.gradle.kts | 2 + 22 files changed, 809 insertions(+), 217 deletions(-) create mode 100644 .gitignore delete mode 100644 MainActivity.kt create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/moviebox/MainActivity.kt create mode 100644 app/src/main/java/com/moviebox/ui/theme/Color.kt create mode 100644 app/src/main/java/com/moviebox/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/moviebox/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..255d589 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Gradle files +.gradle/ +build/ +*/build/ +!gradle/wrapper/gradle-wrapper.jar + +# Local configuration file (sdk path, etc) +local.properties + +# Android Studio +.idea/ +.DS_Store +*.iml + +# Kotlin +*.kotlin_module + +# Generated files +captures/ +.externalNativeBuild/ +.cxx/ + +# Signing files +*.jks +*.keystore + +# Log files +*.log diff --git a/MainActivity.kt b/MainActivity.kt deleted file mode 100644 index f5117d7..0000000 --- a/MainActivity.kt +++ /dev/null @@ -1,217 +0,0 @@ -package com.example.weather - -import android.Manifest -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationManager -import android.os.Bundle -import android.widget.Button -import android.widget.ImageView -import android.widget.TextView -import android.widget.Toast -import android.util.Log -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.text.SimpleDateFormat -import java.util.* - -class MainActivity : AppCompatActivity() { - - private lateinit var temperatureText: TextView - private lateinit var humidityText: TextView - private lateinit var windSpeedText: TextView - private lateinit var pressureText: TextView - private lateinit var visibilityText: TextView - private lateinit var feelsLikeText: TextView - private lateinit var uvIndexText: TextView - private lateinit var gpsButton: Button - private lateinit var errorText: TextView - private lateinit var weatherApiService: WeatherApiService - private var isLocationRequestInProgress = false - - // Инициализация requestPermissionLauncher - private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) { - // Если разрешение получено, получаем местоположение - getWeatherForCurrentLocation() - } else { - Toast.makeText(this, "Разрешение на доступ к геолокации не получено", Toast.LENGTH_SHORT).show() - errorText.text = "Ошибка: Разрешение на доступ к геолокации не получено" - resetLocationRequestState() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - // Инициализация элементов UI - temperatureText = findViewById(R.id.temperatureText) - humidityText = findViewById(R.id.humidityText) - windSpeedText = findViewById(R.id.windSpeedText) - pressureText = findViewById(R.id.pressureText) - visibilityText = findViewById(R.id.visibilityText) - feelsLikeText = findViewById(R.id.feelsLikeText) - uvIndexText = findViewById(R.id.uvIndexText) - gpsButton = findViewById(R.id.gpsButton) - errorText = findViewById(R.id.errorText) - - // Инициализация Retrofit для получения данных о погоде - val retrofit = Retrofit.Builder() - .baseUrl("https://api.weatherapi.com/v1/") - .addConverterFactory(GsonConverterFactory.create()) - .build() - - weatherApiService = retrofit.create(WeatherApiService::class.java) - - gpsButton.setOnClickListener { - if (isLocationRequestInProgress) { - Toast.makeText(this, "Запрос уже выполняется", Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - - try { - isLocationRequestInProgress = true - gpsButton.isEnabled = false - checkLocationPermission() - } catch (e: Exception) { - resetLocationRequestState() - Log.e("MainActivity", "Error in checkLocationPermission: ${e.message}") - errorText.text = "Ошибка: ${e.message}" - } - } - } - - private fun checkLocationPermission() { - try { - // Проверяем, есть ли разрешение на доступ к геолокации - when { - ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED -> { - // Если разрешение есть, получаем местоположение - getWeatherForCurrentLocation() - } - else -> { - // Если разрешение нет, запрашиваем его - requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - } - } catch (e: Exception) { - Log.e("MainActivity", "Error in checkLocationPermission: ${e.message}") - errorText.text = "Ошибка: ${e.message}" - resetLocationRequestState() - } - } - - private fun getWeatherForCurrentLocation() { - try { - val location = getCurrentLocation() - if (location != null) { - fetchWeatherData(location) - } else { - Toast.makeText(this, "Не удалось определить местоположение", Toast.LENGTH_SHORT).show() - errorText.text = "Ошибка: Не удалось определить местоположение" - resetLocationRequestState() - } - } catch (e: SecurityException) { - // Обрабатываем ошибку, если разрешение на геолокацию не было предоставлено - Toast.makeText(this, "Ошибка доступа к геолокации: ${e.message}", Toast.LENGTH_LONG).show() - errorText.text = "Ошибка: ${e.message}" - Log.e("LocationError", "SecurityException: ${e.message}") - resetLocationRequestState() - } catch (e: Exception) { - Log.e("MainActivity", "Error in getWeatherForCurrentLocation: ${e.message}") - errorText.text = "Ошибка: ${e.message}" - resetLocationRequestState() - } - } - - private fun getCurrentLocation(): String? { - try { - val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager - // Проверяем разрешения на доступ к геолокации - if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { - val location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - Log.d("Location", "Location: $location") // Логируем местоположение - return location?.let { "${it.latitude},${it.longitude}" } - } else { - errorText.text = "Ошибка: Разрешение на геолокацию не предоставлено" - resetLocationRequestState() - return null - } - } catch (e: Exception) { - Log.e("MainActivity", "Error in getCurrentLocation: ${e.message}") - errorText.text = "Ошибка: ${e.message}" - resetLocationRequestState() - return null - } - } - - private fun fetchWeatherData(location: String) { - try { - // Получаем данные о погоде через API - val call = weatherApiService.getWeather(location, "de7e08d5708e480bb1b141754252110") - call.enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - val weather = response.body() - if (weather != null) { - // Отображаем данные о погоде - Log.d("WeatherAPI", "Weather Icon: ${weather.weatherIcon}") // Логируем иконку погоды - temperatureText.text = "Температура: ${weather.temperature}°C" - humidityText.text = "Влажность: ${weather.humidity}%" - windSpeedText.text = "Скорость ветра: ${weather.windSpeed} км/ч" - pressureText.text = "Давление: ${weather.pressure} мбар" - visibilityText.text = "Видимость: ${weather.visibility} км" - feelsLikeText.text = "Ощущаемая температура: ${weather.feelsLike}°C" - uvIndexText.text = "Индекс ультрафиолетового излучения: ${weather.uvIndex}" - - // Загружаем иконку погоды с помощью Glide - val iconUrl = if (weather.weatherIcon != null) { - "https://cdn.weatherapi.com/weather/64x64/day/${weather.weatherIcon}.png" - } else { - "https://cdn.weatherapi.com/weather/64x64/day/default.png" // Заглушка, если иконка отсутствует - } - - } - } else { - errorText.text = "Ошибка: ${response.code()} - ${response.message()}" - Log.e("WeatherAPI", "Response error: ${response.message()}") - } - resetLocationRequestState() - } - - override fun onFailure(call: Call, t: Throwable) { - errorText.text = "Ошибка при загрузке данных: ${t.message}" - Log.e("WeatherAPI", "Failure: ${t.message}") - resetLocationRequestState() - } - }) - } catch (e: Exception) { - Log.e("MainActivity", "Error in fetchWeatherData: ${e.message}") - errorText.text = "Ошибка: ${e.message}" - resetLocationRequestState() - } - } - - private fun resetLocationRequestState() { - isLocationRequestInProgress = false - gpsButton.isEnabled = true - } - - // Парсинг восхода и заката - private fun formatSunTime(sunTime: String?): String { - if (sunTime.isNullOrEmpty()) { - return "Не доступно" - } - val inputFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) - val outputFormat = SimpleDateFormat("hh:mm a", Locale.getDefault()) - val date = inputFormat.parse(sunTime) - return outputFormat.format(date) - } -} diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1bb058 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# MovieBox (Моя Кинотека) + +A Jetpack Compose prototype implementing the MovieBox brief: + +- Popular, search, and favorites tabs with bottom navigation +- Detail-style cards with ratings, notes, and watched state +- Repository + ViewModel abstraction ready for Retrofit/Room integration + +## Build + +```bash +./gradlew assembleDebug +``` + +Requires Android SDK 34 and JDK 17. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f70ae36 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.moviebox" + compileSdk = 34 + + defaultConfig { + applicationId = "com.moviebox" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.02.02") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("io.coil-kt:coil-compose:2.5.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e9684a6 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# ProGuard rules for MovieBox +-dontwarn kotlin.** +-dontwarn org.jetbrains.annotations.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8a95234 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/app/src/main/java/com/moviebox/MainActivity.kt b/app/src/main/java/com/moviebox/MainActivity.kt new file mode 100644 index 0000000..74acc47 --- /dev/null +++ b/app/src/main/java/com/moviebox/MainActivity.kt @@ -0,0 +1,416 @@ +package com.moviebox + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.moviebox.ui.theme.MovieBoxTheme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * A lightweight prototype for the MovieBox application described in the project brief. + * The screen demonstrates the main use cases: popular movies, search results, favorites, + * personal notes, ratings and watched flags. Networking and persistence layers are + * represented by [MovieRepository] so they can be swapped for Retrofit/Room later. + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MovieBoxTheme { + val repository = remember { FakeMovieRepository() } + val viewModel: MovieViewModel = viewModel(factory = MovieViewModelFactory(repository)) + MovieBoxApp(viewModel = viewModel) + } + } + } +} + +// Data + repository layer + +data class Movie( + val id: String, + val title: String, + val posterUrl: String, + val overview: String, + val rating: Double, + val year: Int, + val genres: List, + val runtimeMinutes: Int, + val releaseDate: String, + val country: String, + val isFavorite: Boolean = false, + val personalNote: String = "", + val personalRating: Int = 0, + val watched: Boolean = false +) + +interface MovieRepository { + fun popularMovies(): Flow> + fun searchMovies(query: String): Flow> + fun toggleFavorite(movieId: String) + fun updateNote(movieId: String, note: String) + fun updatePersonalRating(movieId: String, rating: Int) + fun toggleWatched(movieId: String) +} + +class FakeMovieRepository : MovieRepository { + private val movies = MutableStateFlow(sampleMovies()) + + override fun popularMovies(): Flow> = movies + + override fun searchMovies(query: String): Flow> { + if (query.isBlank()) return flowOf(emptyList()) + return movies.combine(flowOf(query)) { list, q -> + list.filter { it.title.contains(q, ignoreCase = true) } + } + } + + override fun toggleFavorite(movieId: String) { + movies.update { current -> + current.map { movie -> + if (movie.id == movieId) movie.copy(isFavorite = !movie.isFavorite) else movie + } + } + } + + override fun updateNote(movieId: String, note: String) { + movies.update { current -> + current.map { movie -> if (movie.id == movieId) movie.copy(personalNote = note) else movie } + } + } + + override fun updatePersonalRating(movieId: String, rating: Int) { + movies.update { current -> + current.map { movie -> if (movie.id == movieId) movie.copy(personalRating = rating.coerceIn(1, 5)) else movie } + } + } + + override fun toggleWatched(movieId: String) { + movies.update { current -> + current.map { movie -> if (movie.id == movieId) movie.copy(watched = !movie.watched) else movie } + } + } + + private fun sampleMovies(): List = listOf( + Movie( + id = "1", + title = "Interstellar", + posterUrl = "https://image.tmdb.org/t/p/w500/nBNZadXqJSdt05SHLqgT0HuC5Gm.jpg", + overview = "A team travels through a wormhole in search of a new home for humanity.", + rating = 8.6, + year = 2014, + genres = listOf("Sci-Fi", "Adventure"), + runtimeMinutes = 169, + releaseDate = "2014-11-05", + country = "USA" + ), + Movie( + id = "2", + title = "The Batman", + posterUrl = "https://image.tmdb.org/t/p/w500/74xTEgt7R36Fpooo50r9T25onhq.jpg", + overview = "Batman investigates a series of murders in Gotham City.", + rating = 7.8, + year = 2022, + genres = listOf("Action", "Crime"), + runtimeMinutes = 176, + releaseDate = "2022-03-02", + country = "USA" + ), + Movie( + id = "3", + title = "Spirited Away", + posterUrl = "https://image.tmdb.org/t/p/w500/39wmItIWsg5sZMyRUHLkWBcuVCM.jpg", + overview = "A young girl enters a mysterious spirit world.", + rating = 8.5, + year = 2001, + genres = listOf("Animation", "Fantasy"), + runtimeMinutes = 125, + releaseDate = "2001-07-20", + country = "Japan" + ) + ) +} + +// ViewModel layer + +class MovieViewModel(private val repository: MovieRepository) : ViewModel() { + val popularMovies: Flow> = repository.popularMovies() + private val _searchResults = MutableStateFlow>(emptyList()) + val searchResults: Flow> get() = _searchResults + + fun search(query: String) { + viewModelScope.launch { + repository.searchMovies(query).collect { results -> + _searchResults.value = results + } + } + } + + fun toggleFavorite(movie: Movie) = repository.toggleFavorite(movie.id) + + fun updateNote(movie: Movie, note: String) = repository.updateNote(movie.id, note) + + fun updatePersonalRating(movie: Movie, rating: Int) = repository.updatePersonalRating(movie.id, rating) + + fun toggleWatched(movie: Movie) = repository.toggleWatched(movie.id) +} + +class MovieViewModelFactory(private val repository: MovieRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MovieViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return MovieViewModel(repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} + +// UI layer + +private enum class Screen(val title: String) { Popular("Популярное"), Search("Поиск"), Favorites("Избранное") } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MovieBoxApp(viewModel: MovieViewModel) { + var screen by remember { mutableStateOf(Screen.Popular) } + val topAppBarState = rememberTopAppBarState() + val popular by viewModel.popularMovies.collectAsState(initial = emptyList()) + val searchResults by viewModel.searchResults.collectAsState(initial = emptyList()) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(text = screen.title) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) + ) + }, + bottomBar = { + BottomAppBar( + containerColor = MaterialTheme.colorScheme.primaryContainer, + tonalElevation = 3.dp + ) { + Screen.values().forEach { target -> + val isSelected = target == screen + val label = if (isSelected) "• ${'$'}{target.title}" else target.title + Text( + text = label, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .clickable { screen = target }, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else Color.DarkGray, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + ) { innerPadding -> + when (screen) { + Screen.Popular -> PopularScreen(popular, innerPadding, viewModel::toggleFavorite) + Screen.Search -> SearchScreen(innerPadding, searchResults, onSearch = viewModel::search, onToggleFavorite = viewModel::toggleFavorite) + Screen.Favorites -> FavoritesScreen(popular.filter { it.isFavorite }, innerPadding, viewModel) + } + } +} + +@Composable +private fun PopularScreen(movies: List, padding: PaddingValues, onFavorite: (Movie) -> Unit) { + MovieList( + title = "Популярные фильмы", + movies = movies, + padding = padding, + onFavorite = onFavorite + ) +} + +@Composable +private fun SearchScreen( + padding: PaddingValues, + results: List, + onSearch: (String) -> Unit, + onToggleFavorite: (Movie) -> Unit +) { + var query by remember { mutableStateOf("") } + + Column(modifier = Modifier.padding(padding).padding(16.dp)) { + TextField( + value = query, + onValueChange = { query = it }, + label = { Text("Поиск фильма по названию") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { onSearch(query) }) { + Text("Найти") + } + Spacer(modifier = Modifier.height(8.dp)) + MovieList(title = "Результаты поиска", movies = results, padding = PaddingValues(), onFavorite = onToggleFavorite) + if (query.isNotBlank() && results.isEmpty()) { + Text(text = "Ничего не найдено", modifier = Modifier.padding(8.dp), color = Color.Red) + } + } +} + +@Composable +private fun FavoritesScreen(favorites: List, padding: PaddingValues, viewModel: MovieViewModel) { + Column(modifier = Modifier.padding(padding)) { + if (favorites.isEmpty()) { + Text("Список избранного пуст", modifier = Modifier.padding(16.dp)) + } else { + LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(favorites) { movie -> + FavoriteCard( + movie = movie, + onToggleFavorite = { viewModel.toggleFavorite(movie) }, + onUpdateNote = { viewModel.updateNote(movie, it) }, + onUpdateRating = { viewModel.updatePersonalRating(movie, it) }, + onToggleWatched = { viewModel.toggleWatched(movie) } + ) + } + } + } + } +} + +@Composable +private fun MovieList(title: String, movies: List, padding: PaddingValues, onFavorite: (Movie) -> Unit) { + Column(modifier = Modifier.padding(padding)) { + Text(text = title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(16.dp)) + LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(movies) { movie -> + MovieCard(movie = movie, onFavorite = { onFavorite(movie) }) + } + } + } +} + +@Composable +private fun MovieCard(movie: Movie, onFavorite: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(12.dp)) { + AsyncImage( + model = movie.posterUrl, + contentDescription = movie.title, + modifier = Modifier.height(140.dp), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(movie.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text("Год: ${'$'}{movie.year} • Рейтинг TMDB: ${'$'}{movie.rating}") + Text("Жанры: ${'$'}{movie.genres.joinToString()}", maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("Длительность: ${'$'}{movie.runtimeMinutes} мин") + Text(movie.overview, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(vertical = 4.dp)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Button(onClick = onFavorite) { + Text(if (movie.isFavorite) "Убрать из избранного" else "В избранное") + } + } + } + } + } +} + +@Composable +private fun FavoriteCard( + movie: Movie, + onToggleFavorite: () -> Unit, + onUpdateNote: (String) -> Unit, + onUpdateRating: (Int) -> Unit, + onToggleWatched: () -> Unit +) { + var note by remember(movie.id) { mutableStateOf(movie.personalNote) } + var ratingInput by remember(movie.id) { mutableStateOf(movie.personalRating.takeIf { it > 0 }?.toString() ?: "") } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = movie.posterUrl, + contentDescription = movie.title, + modifier = Modifier.height(120.dp), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text(movie.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text("Личная оценка: ${'$'}{if (movie.personalRating > 0) movie.personalRating else "нет"}") + Text("Просмотрено: ${'$'}{if (movie.watched) "да" else "нет"}") + } + } + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = note, + onValueChange = { note = it }, + label = { Text("Заметка") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextField( + value = ratingInput, + onValueChange = { ratingInput = it.filter { ch -> ch.isDigit() }.take(1) }, + label = { Text("Оценка 1-5") }, + modifier = Modifier.weight(1f) + ) + Button(onClick = { + ratingInput.toIntOrNull()?.let(onUpdateRating) + }) { Text("Сохранить оценку") } + } + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { onUpdateNote(note) }) { Text("Сохранить заметку") } + Button(onClick = onToggleWatched) { Text(if (movie.watched) "Пометить как не просмотрено" else "Пометить как просмотрено") } + Button(onClick = onToggleFavorite) { Text("Удалить из избранного") } + } + } + } +} diff --git a/app/src/main/java/com/moviebox/ui/theme/Color.kt b/app/src/main/java/com/moviebox/ui/theme/Color.kt new file mode 100644 index 0000000..dff0fe9 --- /dev/null +++ b/app/src/main/java/com/moviebox/ui/theme/Color.kt @@ -0,0 +1,61 @@ +package com.moviebox.ui.theme + +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF6750A4) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFEADDFF) +val md_theme_light_onPrimaryContainer = Color(0xFF21005D) +val md_theme_light_secondary = Color(0xFF625B71) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFE8DEF8) +val md_theme_light_onSecondaryContainer = Color(0xFF1D192B) +val md_theme_light_tertiary = Color(0xFF7D5260) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFD8E4) +val md_theme_light_onTertiaryContainer = Color(0xFF31111D) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFEF7FF) +val md_theme_light_onBackground = Color(0xFF1D1B20) +val md_theme_light_surface = Color(0xFFFEF7FF) +val md_theme_light_onSurface = Color(0xFF1D1B20) +val md_theme_light_surfaceVariant = Color(0xFFE7E0EC) +val md_theme_light_onSurfaceVariant = Color(0xFF49454F) +val md_theme_light_outline = Color(0xFF7A757F) +val md_theme_light_inverseOnSurface = Color(0xFFF5EFF7) +val md_theme_light_inverseSurface = Color(0xFF322F35) +val md_theme_light_inversePrimary = Color(0xFFD0BCFF) +val md_theme_light_surfaceTint = md_theme_light_primary + +val md_theme_dark_primary = Color(0xFFD0BCFF) +val md_theme_dark_onPrimary = Color(0xFF381E72) +val md_theme_dark_primaryContainer = Color(0xFF4F378B) +val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF) +val md_theme_dark_secondary = Color(0xFFCCC2DC) +val md_theme_dark_onSecondary = Color(0xFF332D41) +val md_theme_dark_secondaryContainer = Color(0xFF4A4458) +val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8) +val md_theme_dark_tertiary = Color(0xFFEFB8C8) +val md_theme_dark_onTertiary = Color(0xFF492532) +val md_theme_dark_tertiaryContainer = Color(0xFF633B48) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFD8E4) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF141218) +val md_theme_dark_onBackground = Color(0xFFE6E0E9) +val md_theme_dark_surface = Color(0xFF141218) +val md_theme_dark_onSurface = Color(0xFFE6E0E9) +val md_theme_dark_surfaceVariant = Color(0xFF49454F) +val md_theme_dark_onSurfaceVariant = Color(0xFFCAC4D0) +val md_theme_dark_outline = Color(0xFF948F99) +val md_theme_dark_inverseOnSurface = Color(0xFF1D1B20) +val md_theme_dark_inverseSurface = Color(0xFFE6E0E9) +val md_theme_dark_inversePrimary = Color(0xFF6750A4) +val md_theme_dark_surfaceTint = md_theme_dark_primary + +val seed = Color(0xFF6750A4) diff --git a/app/src/main/java/com/moviebox/ui/theme/Theme.kt b/app/src/main/java/com/moviebox/ui/theme/Theme.kt new file mode 100644 index 0000000..288f234 --- /dev/null +++ b/app/src/main/java/com/moviebox/ui/theme/Theme.kt @@ -0,0 +1,94 @@ +package com.moviebox.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, +) + +private val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, +) + +@Composable +fun MovieBoxTheme( + useDarkTheme: Boolean = isSystemInDarkTheme(), + useDynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + useDarkTheme -> DarkColors + else -> LightColors + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/moviebox/ui/theme/Type.kt b/app/src/main/java/com/moviebox/ui/theme/Type.kt new file mode 100644 index 0000000..8b4bda4 --- /dev/null +++ b/app/src/main/java/com/moviebox/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package com.moviebox.ui.theme + +import androidx.compose.material3.Typography + +val Typography = Typography() diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ca0b72e --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..dde81fb --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #6750A4 + #FFFFFF + #FFFBFE + #4E7CF4 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a1839ac --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + MovieBox + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..0bbe5cd --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..30688f3 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} + +tasks.register("clean", Delete::class) { + delete(rootProject.buildDir) +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2ac64d9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +# Project-wide Gradle settings +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +org.gradle.configuration-cache=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..84a0b92 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cee4b60 --- /dev/null +++ b/gradlew @@ -0,0 +1,18 @@ +#!/usr/bin/env sh + +DIR="$(cd "$(dirname "$0")" && pwd)" + +JAVA_HOME=${JAVA_HOME:-} + +if [ -z "$JAVA_HOME" ]; then + JAVA_CMD=java +else + JAVA_CMD="$JAVA_HOME/bin/java" +fi + +"$JAVA_CMD" -version >/dev/null 2>&1 || { + echo "Java not found, please install JDK" >&2 + exit 1 +} + +exec "$JAVA_CMD" -Dorg.gradle.appname=gradlew -classpath "$DIR/gradle/wrapper/gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..de51198 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,9 @@ +@ECHO OFF +SET DIR=%~dp0 +SET JAVA_EXE=java +IF NOT "%JAVA_HOME%"=="" SET JAVA_EXE=%JAVA_HOME%\bin\java +"%JAVA_EXE%" -version >NUL 2>&1 || ( + ECHO Java not found, please install JDK + EXIT /B 1 +) +"%JAVA_EXE%" -Dorg.gradle.appname=gradlew -classpath "%DIR%\gradle\wrapper\gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain %* diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..453ef92 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "MovieBox" +include(":app")