commit aeb51fdb3a5b903d3e6b4a0cb178eaf54a26546c Author: hhu67 Date: Thu Aug 20 00:03:58 2026 +0300 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9743056 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.iml +app/build/outputs/apk/debug/app-debug.apk +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..b3405b3 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +My Application \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..bcf4711 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..1c88427 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..cdbc250 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..29a5cc0 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..3001917 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,80 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.sergay.hhu67.pw.app" + compileSdk { + version = release(37) + } + + defaultConfig { + applicationId = "com.sergay.hhu67.pw.app" + minSdk = 24 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + optimization { + enable = false + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation("androidx.compose.material:material-icons-extended") + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation("io.coil-kt:coil-video:2.6.0") + + // Сеть и картинки + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("io.coil-kt:coil-compose:2.6.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4") + implementation("androidx.compose.material:material-icons-extended") + + // Плеер ExoPlayer для аудио и видео + implementation("androidx.media3:media3-exoplayer:1.4.1") + implementation("androidx.media3:media3-ui:1.4.1") + testImplementation(libs.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("io.coil-kt:coil-compose:2.6.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4") +} diff --git a/app/src/androidTest/java/com/sergay/hhu67/pw/app/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/sergay/hhu67/pw/app/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..9dd290e --- /dev/null +++ b/app/src/androidTest/java/com/sergay/hhu67/pw/app/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.sergay.hhu67.pw.app + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.sergay.hhu67.pw.app", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..67c8fb1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/sergay/hhu67/pw/app/MainActivity.kt b/app/src/main/java/com/sergay/hhu67/pw/app/MainActivity.kt new file mode 100644 index 0000000..4bf0d55 --- /dev/null +++ b/app/src/main/java/com/sergay/hhu67/pw/app/MainActivity.kt @@ -0,0 +1,1152 @@ +package com.sergay.hhu67.pw.app + +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil.ImageLoader +import coil.compose.AsyncImage +import coil.decode.VideoFrameDecoder +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.launch +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.* +import java.io.InputStream +import java.util.concurrent.TimeUnit +import kotlin.math.ceil + +// ========================================== +// 1. СЕТЕВЫЕ МОДЕЛИ И API +// ========================================== + +data class IpInfo( + @SerializedName("ip") val ip: String?, + @SerializedName("country") val country: String?, + @SerializedName("city") val city: String? +) + +data class AnswerGet( + @SerializedName("id") val id: Int, + @SerializedName("link") val link: String?, + @SerializedName("name") val name: String?, + @SerializedName("des") val des: String? +) + +data class AnswerList( + @SerializedName("total") val total: Int, + @SerializedName("items") val items: List? +) + +data class AnswerAdmin( + @SerializedName("id") val id: Int, + @SerializedName("username") val username: String, + @SerializedName("ip") val ip: String?, + @SerializedName("country") val country: String?, + @SerializedName("city") val city: String?, + @SerializedName("is_admin") val isAdmin: Boolean +) + +data class AnswerAdminList( + @SerializedName("total") val total: Int, + @SerializedName("items") val items: List? +) + +data class AnswerBot( + @SerializedName("id") val id: Int, + @SerializedName("phrase") val phrase: String +) + +data class AnswerBotList( + @SerializedName("total") val total: Int, + @SerializedName("items") val items: List? +) + +class AppCookieJar : CookieJar { + private val cookieStore = mutableListOf() + override fun saveFromResponse(url: HttpUrl, cookies: List) { + cookieStore.removeAll { existing -> cookies.any { it.name == existing.name } } + cookieStore.addAll(cookies) + } + override fun loadForRequest(url: HttpUrl): List = cookieStore + fun clear() = cookieStore.clear() +} + +val cookieJar = AppCookieJar() + +interface IpService { + @GET("/") + suspend fun getIpInfo(): IpInfo +} + +interface ApiService { + @FormUrlEncoded + @POST("api/login") + suspend fun login( + @Field("username") user: String, + @Field("password") pass: String, + @Field("browser") browser: String = "Android Kotlin Client", + @Field("ip") ip: String, + @Field("country") country: String, + @Field("city") city: String + ): Response + + @GET("api/get/list") + suspend fun getImages(@Query("limit") limit: Int, @Query("offset") offset: Int): AnswerList + + @GET("api/get/audio/list") + suspend fun getAudio(@Query("limit") limit: Int, @Query("offset") offset: Int): AnswerList + + @GET("api/get/video/list") + suspend fun getVideo(@Query("limit") limit: Int, @Query("offset") offset: Int): AnswerList + + @GET("api/get/bot/list/") + suspend fun getBotQuotes(@Query("limit") limit: Int, @Query("offset") offset: Int): AnswerBotList + + @GET("api/get/admin/list/") + suspend fun getUsers(@Query("limit") limit: Int, @Query("offset") offset: Int): AnswerAdminList + + @FormUrlEncoded + @POST("api/post/update/") + suspend fun updateImage(@Field("id") id: Int, @Field("name") name: String, @Field("des") des: String): Response + + @FormUrlEncoded + @POST("api/post/update/audio/") + suspend fun updateAudio(@Field("id") id: Int, @Field("name") name: String, @Field("des") des: String): Response + + @FormUrlEncoded + @POST("api/post/update/video/") + suspend fun updateVideo(@Field("id") id: Int, @Field("name") name: String, @Field("des") des: String): Response + + @FormUrlEncoded + @POST("api/post/delete/") + suspend fun deleteImage(@Field("id") id: Int): Response + + @FormUrlEncoded + @POST("api/post/delete/audio/") + suspend fun deleteAudio(@Field("id") id: Int): Response + + @FormUrlEncoded + @POST("api/post/delete/video/") + suspend fun deleteVideo(@Field("id") id: Int): Response + + @Multipart + @POST("api/post/user/") + suspend fun uploadImage(@Part image: MultipartBody.Part, @Part("name") name: RequestBody, @Part("des") des: RequestBody): Response + + @Multipart + @POST("api/post/audio/user/") + suspend fun uploadAudio(@Part audio: MultipartBody.Part, @Part("name") name: RequestBody, @Part("des") des: RequestBody): Response + + @Multipart + @POST("api/post/video/user/") + suspend fun uploadVideo(@Part video: MultipartBody.Part, @Part("name") name: RequestBody, @Part("des") des: RequestBody): Response + + @FormUrlEncoded + @POST("api/post/delete/admin/user/") + suspend fun deleteUser(@Field("username") username: String): Response + + @FormUrlEncoded + @POST("api/post/update/bot/") + suspend fun updateBotPhrase(@Field("id") id: Int, @Field("phrase") phrase: String): Response + + @FormUrlEncoded + @POST("api/post/delete/bot/") + suspend fun deleteBotPhrase(@Field("id") id: Int): Response + + companion object { + fun create(): ApiService { + val okHttp = OkHttpClient.Builder() + .cookieJar(cookieJar) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + return Retrofit.Builder() + .baseUrl("https://sergay.hhu67.pw/") + .client(okHttp) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ApiService::class.java) + } + + fun createIpService(): IpService { + return Retrofit.Builder() + .baseUrl("https://ip.hhu67.pw/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(IpService::class.java) + } + } +} + +// ========================================== +// 2. VIEWMODEL +// ========================================== + +enum class AppTab(val title: String) { + IMAGES("Картинки"), + AUDIO("Аудио"), + VIDEO("Видео"), + ADMIN("Админка") +} + +sealed interface ContentState { + object Loading : ContentState + data class SuccessItems(val items: List) : ContentState + data class Error(val message: String) : ContentState +} + +class MainViewModel : ViewModel() { + private val api = ApiService.create() + private val ipApi = ApiService.createIpService() + + var isAuthenticated by mutableStateOf(false) + var isAdmin by mutableStateOf(false) + var isAuthLoading by mutableStateOf(false) + + var currentTab by mutableStateOf(AppTab.IMAGES) + var state by mutableStateOf(ContentState.Loading) + + // Пагинация для медиа (Картинки: 20, Аудио: 20, Видео: 10) + var currentPage by mutableStateOf(0) + var totalItemsCount by mutableStateOf(0) + + // Модальное окно просмотра Фото / Видео + var previewMediaItem by mutableStateOf(null) + var isPreviewVideo by mutableStateOf(false) + + // Аудиоплеер (Нижняя строка) + var audioPlaylist by mutableStateOf>(emptyList()) + var currentAudioIndex by mutableStateOf(-1) + var isAudioPlaying by mutableStateOf(false) + + // Админка: юзеры + var adminUsers by mutableStateOf>(emptyList()) + var usersTotalCount by mutableStateOf(0) + var usersCurrentPage by mutableStateOf(0) + + // Админка: фразы бота + var adminQuotes by mutableStateOf>(emptyList()) + var quotesTotalCount by mutableStateOf(0) + var quotesCurrentPage by mutableStateOf(0) + + fun login(user: String, pass: String, onResult: (Boolean, String) -> Unit) { + viewModelScope.launch { + isAuthLoading = true + try { + val ipInfo = try { + ipApi.getIpInfo() + } catch (_: Exception) { + IpInfo("127.0.0.1", "Unknown", "Unknown") + } + + val resp = api.login( + user = user, + pass = pass, + ip = ipInfo.ip ?: "127.0.0.1", + country = ipInfo.country ?: "Unknown", + city = ipInfo.city ?: "Unknown" + ) + + if (resp.isSuccessful) { + isAuthenticated = true + try { + api.getUsers(limit = 1, offset = 0) + isAdmin = true + onResult(true, "Вход выполнен: Администратор") + } catch (e: retrofit2.HttpException) { + isAdmin = false + onResult(true, "Вход выполнен: Пользователь") + } + selectTab(AppTab.IMAGES) + } else { + onResult(false, "Неверный логин или пароль") + } + } catch (e: Exception) { + onResult(false, e.localizedMessage ?: "Ошибка сети") + } finally { + isAuthLoading = false + } + } + } + + fun logout() { + cookieJar.clear() + isAuthenticated = false + isAdmin = false + currentTab = AppTab.IMAGES + currentAudioIndex = -1 + isAudioPlaying = false + currentPage = 0 + } + + fun selectTab(tab: AppTab) { + currentTab = tab + currentPage = 0 + if (tab == AppTab.ADMIN) { + loadAdminUsers(0) + loadAdminQuotes(0) + } else { + loadMediaData(0) + } + } + + fun loadMediaData(page: Int = currentPage) { + state = ContentState.Loading + currentPage = page + viewModelScope.launch { + try { + when (currentTab) { + AppTab.IMAGES -> { + val limit = 20 + val offset = page * limit + val res = api.getImages(limit = limit, offset = offset) + totalItemsCount = res.total + state = ContentState.SuccessItems(res.items ?: emptyList()) + } + AppTab.AUDIO -> { + val limit = 20 + val offset = page * limit + val res = api.getAudio(limit = limit, offset = offset) + totalItemsCount = res.total + val list = res.items ?: emptyList() + audioPlaylist = list + state = ContentState.SuccessItems(list) + } + AppTab.VIDEO -> { + val limit = 10 + val offset = page * limit + val res = api.getVideo(limit = limit, offset = offset) + totalItemsCount = res.total + state = ContentState.SuccessItems(res.items ?: emptyList()) + } + AppTab.ADMIN -> {} + } + } catch (e: Exception) { + state = ContentState.Error(e.localizedMessage ?: "Ошибка загрузки") + } + } + } + + fun loadAdminUsers(page: Int) { + viewModelScope.launch { + try { + val offset = page * 20 + val res = api.getUsers(limit = 20, offset = offset) + adminUsers = res.items ?: emptyList() + usersTotalCount = res.total + usersCurrentPage = page + } catch (_: Exception) {} + } + } + + fun loadAdminQuotes(page: Int) { + viewModelScope.launch { + try { + val offset = page * 20 + val res = api.getBotQuotes(limit = 20, offset = offset) + adminQuotes = res.items ?: emptyList() + quotesTotalCount = res.total + quotesCurrentPage = page + } catch (_: Exception) {} + } + } + + fun playAudioTrack(index: Int) { + if (index in audioPlaylist.indices) { + currentAudioIndex = index + isAudioPlaying = true + } + } + + fun nextAudio() { + if (audioPlaylist.isNotEmpty()) { + currentAudioIndex = (currentAudioIndex + 1) % audioPlaylist.size + isAudioPlaying = true + } + } + + fun prevAudio() { + if (audioPlaylist.isNotEmpty()) { + currentAudioIndex = if (currentAudioIndex - 1 < 0) audioPlaylist.size - 1 else currentAudioIndex - 1 + isAudioPlaying = true + } + } + + fun deleteItem(id: Int) { + viewModelScope.launch { + try { + when (currentTab) { + AppTab.IMAGES -> api.deleteImage(id) + AppTab.AUDIO -> api.deleteAudio(id) + AppTab.VIDEO -> api.deleteVideo(id) + else -> {} + } + previewMediaItem = null + loadMediaData(currentPage) + } catch (_: Exception) {} + } + } + + fun updateItem(id: Int, name: String, des: String) { + viewModelScope.launch { + try { + when (currentTab) { + AppTab.IMAGES -> api.updateImage(id, name, des) + AppTab.AUDIO -> api.updateAudio(id, name, des) + AppTab.VIDEO -> api.updateVideo(id, name, des) + else -> {} + } + previewMediaItem = null + loadMediaData(currentPage) + } catch (_: Exception) {} + } + } + + fun deleteUser(username: String) { + viewModelScope.launch { + try { + api.deleteUser(username) + loadAdminUsers(usersCurrentPage) + } catch (_: Exception) {} + } + } + + fun updateBotPhrase(id: Int, phrase: String) { + viewModelScope.launch { + try { + api.updateBotPhrase(id, phrase) + loadAdminQuotes(quotesCurrentPage) + } catch (_: Exception) {} + } + } + + fun deleteBotPhrase(id: Int) { + viewModelScope.launch { + try { + api.deleteBotPhrase(id) + loadAdminQuotes(quotesCurrentPage) + } catch (_: Exception) {} + } + } + + fun uploadMedia(bytes: ByteArray, fileName: String, name: String, des: String) { + viewModelScope.launch { + state = ContentState.Loading + try { + val reqFile = bytes.toRequestBody("multipart/form-data".toMediaTypeOrNull()) + val namePart = name.toRequestBody("text/plain".toMediaTypeOrNull()) + val desPart = des.toRequestBody("text/plain".toMediaTypeOrNull()) + + when (currentTab) { + AppTab.IMAGES -> api.uploadImage(MultipartBody.Part.createFormData("image", fileName, reqFile), namePart, desPart) + AppTab.AUDIO -> api.uploadAudio(MultipartBody.Part.createFormData("audio", fileName, reqFile), namePart, desPart) + AppTab.VIDEO -> api.uploadVideo(MultipartBody.Part.createFormData("video", fileName, reqFile), namePart, desPart) + else -> {} + } + loadMediaData(0) + } catch (e: Exception) { + state = ContentState.Error("Ошибка загрузки: ${e.localizedMessage}") + } + } + } +} + +// ========================================== +// 3. ЭКРАНЫ +// ========================================== + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme(colorScheme = darkColorScheme( + primary = Color(0xFFBB86FC), + surface = Color(0xFF1E1E1E), + background = Color(0xFF121212) + )) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + val vm: MainViewModel = viewModel() + if (!vm.isAuthenticated) { + AuthScreen(vm = vm) + } else { + MainAppScreen(vm = vm) + } + } + } + } + } +} + +@Composable +fun AuthScreen(vm: MainViewModel) { + val ctx = LocalContext.current + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Авторизация / Регистрация", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Имя пользователя") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Пароль") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(20.dp)) + + if (vm.isAuthLoading) { + CircularProgressIndicator() + } else { + Button( + onClick = { + if (username.isBlank() || password.isBlank()) { + Toast.makeText(ctx, "Заполните все поля", Toast.LENGTH_SHORT).show() + } else { + vm.login(username, password) { _, msg -> + Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show() + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Войти / Зарегистрироваться") + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainAppScreen(vm: MainViewModel) { + val ctx = LocalContext.current + var showAddDialog by remember { mutableStateOf(false) } + var editItem by remember { mutableStateOf(null) } + + val exoPlayer = remember { ExoPlayer.Builder(ctx).build() } + + // Настройка Coil для превью кадров из видео + val imageLoader = remember { + ImageLoader.Builder(ctx) + .components { + add(VideoFrameDecoder.Factory()) + } + .build() + } + + LaunchedEffect(vm.currentAudioIndex, vm.isAudioPlaying) { + if (vm.currentAudioIndex in vm.audioPlaylist.indices) { + val track = vm.audioPlaylist[vm.currentAudioIndex] + if (!track.link.isNullOrEmpty()) { + val currentMedia = exoPlayer.currentMediaItem?.mediaId + if (currentMedia != track.link) { + exoPlayer.setMediaItem(MediaItem.Builder().setUri(track.link).setMediaId(track.link).build()) + exoPlayer.prepare() + } + if (vm.isAudioPlaying) exoPlayer.play() else exoPlayer.pause() + } + } + } + + DisposableEffect(Unit) { + onDispose { exoPlayer.release() } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(vm.currentTab.title) }, + actions = { + IconButton(onClick = { vm.logout() }) { + Icon(Icons.Default.Logout, contentDescription = "Выйти") + } + IconButton(onClick = { + if (vm.currentTab == AppTab.ADMIN) { + vm.loadAdminUsers(vm.usersCurrentPage) + vm.loadAdminQuotes(vm.quotesCurrentPage) + } else { + vm.loadMediaData(vm.currentPage) + } + }) { + Icon(Icons.Default.Refresh, contentDescription = "Обновить") + } + } + ) + }, + floatingActionButton = { + if (vm.isAdmin && vm.currentTab != AppTab.ADMIN) { + FloatingActionButton(onClick = { showAddDialog = true }) { + Icon(Icons.Default.Add, contentDescription = "Добавить") + } + } + }, + bottomBar = { + Column { + if (vm.currentAudioIndex in vm.audioPlaylist.indices) { + val currentTrack = vm.audioPlaylist[vm.currentAudioIndex] + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = currentTrack.name ?: "Аудиозапись", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) + Text( + text = currentTrack.des ?: "", + style = MaterialTheme.typography.labelSmall, + color = Color.LightGray, + maxLines = 1 + ) + } + IconButton(onClick = { vm.prevAudio() }) { + Icon(Icons.Default.SkipPrevious, contentDescription = "Назад") + } + IconButton(onClick = { vm.isAudioPlaying = !vm.isAudioPlaying }) { + Icon(if (vm.isAudioPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, null) + } + IconButton(onClick = { vm.nextAudio() }) { + Icon(Icons.Default.SkipNext, contentDescription = "Вперед") + } + } + } + } + + NavigationBar { + NavigationBarItem( + selected = vm.currentTab == AppTab.IMAGES, + onClick = { vm.selectTab(AppTab.IMAGES) }, + icon = { Icon(Icons.Default.Image, null) }, + label = { Text("Фото") } + ) + NavigationBarItem( + selected = vm.currentTab == AppTab.AUDIO, + onClick = { vm.selectTab(AppTab.AUDIO) }, + icon = { Icon(Icons.Default.Audiotrack, null) }, + label = { Text("Аудио") } + ) + NavigationBarItem( + selected = vm.currentTab == AppTab.VIDEO, + onClick = { vm.selectTab(AppTab.VIDEO) }, + icon = { Icon(Icons.Default.Videocam, null) }, + label = { Text("Видео") } + ) + if (vm.isAdmin) { + NavigationBarItem( + selected = vm.currentTab == AppTab.ADMIN, + onClick = { vm.selectTab(AppTab.ADMIN) }, + icon = { Icon(Icons.Default.AdminPanelSettings, null) }, + label = { Text("Админка") } + ) + } + } + } + } + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + if (vm.currentTab == AppTab.ADMIN) { + AdminDashboard(vm = vm) + } else { + val pageSize = if (vm.currentTab == AppTab.VIDEO) 10 else 20 + val totalPages = maxOf(1, ceil(vm.totalItemsCount.toDouble() / pageSize).toInt()) + + when (val s = vm.state) { + is ContentState.Loading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + is ContentState.Error -> Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(s.message, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + Button(onClick = { vm.loadMediaData(vm.currentPage) }) { Text("Повторить") } + } + is ContentState.SuccessItems -> { + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f)) { + if (vm.currentTab == AppTab.IMAGES) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(s.items, key = { it.id }) { item -> + ImageGridItem(item = item, onClick = { + vm.previewMediaItem = item + vm.isPreviewVideo = false + }) + } + } + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(s.items.size) { index -> + val item = s.items[index] + MediaListItem( + item = item, + tab = vm.currentTab, + imageLoader = imageLoader, + onPlayAudio = { vm.playAudioTrack(index) }, + onOpenVideo = { + vm.previewMediaItem = item + vm.isPreviewVideo = true + } + ) + } + } + } + } + + // Пагинатор для медиа + PaginationControls( + currentPage = vm.currentPage, + totalPages = totalPages, + onPageSelected = { vm.loadMediaData(it) } + ) + } + } + } + } + } + } + + // Модальное окно полного просмотра + vm.previewMediaItem?.let { item -> + Dialog(onDismissRequest = { vm.previewMediaItem = null }) { + Card(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { + Column(modifier = Modifier.padding(16.dp)) { + if (vm.isPreviewVideo && !item.link.isNullOrEmpty()) { + VideoPlayerView(url = item.link) + } else if (!item.link.isNullOrEmpty()) { + AsyncImage( + model = item.link, + contentDescription = null, + modifier = Modifier.fillMaxWidth().height(260.dp), + contentScale = ContentScale.Fit + ) + } + Spacer(Modifier.height(12.dp)) + Text(text = item.name ?: "Без названия", style = MaterialTheme.typography.titleLarge) + if (!item.des.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text(text = item.des, style = MaterialTheme.typography.bodyMedium, color = Color.LightGray) + } + + Spacer(Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (vm.isAdmin) { + IconButton(onClick = { + editItem = item + vm.previewMediaItem = null + }) { + Icon(Icons.Default.Edit, contentDescription = "Редактировать") + } + IconButton(onClick = { vm.deleteItem(item.id) }) { + Icon(Icons.Default.Delete, contentDescription = "Удалить", tint = MaterialTheme.colorScheme.error) + } + } + Spacer(Modifier.weight(1f)) + Button(onClick = { vm.previewMediaItem = null }) { + Text("Закрыть") + } + } + } + } + } + } + + if (showAddDialog) { + UploadDialog( + tab = vm.currentTab, + onDismiss = { showAddDialog = false }, + onUpload = { bytes, fileName, name, des -> + vm.uploadMedia(bytes, fileName, name, des) + showAddDialog = false + } + ) + } + + editItem?.let { item -> + EditDialog( + item = item, + onDismiss = { editItem = null }, + onSave = { id, name, des -> + vm.updateItem(id, name, des) + editItem = null + } + ) + } +} + +// ========================================== +// 4. ЭЛЕМЕНТЫ СПИСКОВ И СЕТКИ +// ========================================== + +@Composable +fun ImageGridItem(item: AnswerGet, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + Column { + AsyncImage( + model = item.link, + contentDescription = item.name, + modifier = Modifier + .fillMaxWidth() + .height(160.dp), + contentScale = ContentScale.Crop + ) + Column(modifier = Modifier.padding(8.dp)) { + Text( + text = item.name ?: "Без названия", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) + if (!item.des.isNullOrBlank()) { + Text( + text = item.des, + style = MaterialTheme.typography.labelSmall, + color = Color.LightGray, + maxLines = 2 + ) + } + } + } + } +} + +@Composable +fun MediaListItem( + item: AnswerGet, + tab: AppTab, + imageLoader: ImageLoader, + onPlayAudio: () -> Unit, + onOpenVideo: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (tab == AppTab.VIDEO) onOpenVideo() + } + ) { + Column { + // Превью-обложка для видео + if (tab == AppTab.VIDEO && !item.link.isNullOrEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + ) { + AsyncImage( + model = item.link, + imageLoader = imageLoader, + contentDescription = "Video preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Surface( + modifier = Modifier.align(Alignment.Center), + color = Color.Black.copy(alpha = 0.6f), + shape = MaterialTheme.shapes.extraLarge + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Play", + tint = Color.White, + modifier = Modifier.size(48.dp).padding(8.dp) + ) + } + } + } + + Column(modifier = Modifier.padding(16.dp)) { + Text(text = item.name ?: "Без названия", style = MaterialTheme.typography.titleMedium) + if (!item.des.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text(text = item.des, style = MaterialTheme.typography.bodyMedium, color = Color.LightGray) + } + Spacer(Modifier.height(8.dp)) + if (tab == AppTab.AUDIO) { + Button(onClick = onPlayAudio) { + Icon(Icons.Default.PlayArrow, null) + Spacer(Modifier.width(4.dp)) + Text("Включить") + } + } + } + } + } +} + +@Composable +fun VideoPlayerView(url: String) { + val context = LocalContext.current + val player = remember(url) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(Uri.parse(url))) + prepare() + playWhenReady = true + } + } + + DisposableEffect(Unit) { + onDispose { player.release() } + } + + AndroidView( + factory = { + PlayerView(it).apply { + this.player = player + useController = true + } + }, + modifier = Modifier.fillMaxWidth().height(230.dp) + ) +} + +// ========================================== +// 5. АДМИН-ПАНЕЛЬ +// ========================================== + +@Composable +fun AdminDashboard(vm: MainViewModel) { + var adminTab by remember { mutableStateOf(0) } + var editQuoteItem by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + TabRow(selectedTabIndex = adminTab) { + Tab(selected = adminTab == 0, onClick = { adminTab = 0 }, text = { Text("Юзеры (${vm.usersTotalCount})") }) + Tab(selected = adminTab == 1, onClick = { adminTab = 1 }, text = { Text("Фразы бота (${vm.quotesTotalCount})") }) + } + + if (adminTab == 0) { + val totalPages = maxOf(1, ceil(vm.usersTotalCount / 20.0).toInt()) + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(vm.adminUsers, key = { it.id }) { u -> + Card(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("${u.username} ${if (u.isAdmin) "(Админ)" else ""}", style = MaterialTheme.typography.titleMedium) + Text("IP: ${u.ip ?: "-"} | ${u.country ?: ""} ${u.city ?: ""}", style = MaterialTheme.typography.labelSmall, color = Color.Gray) + } + IconButton(onClick = { vm.deleteUser(u.username) }) { + Icon(Icons.Default.Delete, contentDescription = "Удалить", tint = MaterialTheme.colorScheme.error) + } + } + } + } + } + PaginationControls( + currentPage = vm.usersCurrentPage, + totalPages = totalPages, + onPageSelected = { vm.loadAdminUsers(it) } + ) + } + } else { + val totalPages = maxOf(1, ceil(vm.quotesTotalCount / 20.0).toInt()) + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(vm.adminQuotes, key = { it.id }) { q -> + Card(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Text(text = q.phrase, modifier = Modifier.weight(1f)) + IconButton(onClick = { editQuoteItem = q }) { + Icon(Icons.Default.Edit, contentDescription = "Редактировать") + } + IconButton(onClick = { vm.deleteBotPhrase(q.id) }) { + Icon(Icons.Default.Delete, contentDescription = "Удалить", tint = MaterialTheme.colorScheme.error) + } + } + } + } + } + PaginationControls( + currentPage = vm.quotesCurrentPage, + totalPages = totalPages, + onPageSelected = { vm.loadAdminQuotes(it) } + ) + } + } + } + + editQuoteItem?.let { q -> + var newPhrase by remember { mutableStateOf(q.phrase) } + AlertDialog( + onDismissRequest = { editQuoteItem = null }, + title = { Text("Редактировать фразу") }, + text = { + OutlinedTextField( + value = newPhrase, + onValueChange = { newPhrase = it }, + label = { Text("Фраза") }, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + Button(onClick = { + vm.updateBotPhrase(q.id, newPhrase) + editQuoteItem = null + }) { Text("Сохранить") } + }, + dismissButton = { TextButton(onClick = { editQuoteItem = null }) { Text("Отмена") } } + ) + } +} + +@Composable +fun PaginationControls(currentPage: Int, totalPages: Int, onPageSelected: (Int) -> Unit) { + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { onPageSelected(currentPage - 1) }, enabled = currentPage > 0) { + Icon(Icons.Default.ChevronLeft, contentDescription = "Назад") + } + Text("Стр. ${currentPage + 1} из $totalPages", modifier = Modifier.padding(horizontal = 16.dp)) + IconButton(onClick = { onPageSelected(currentPage + 1) }, enabled = currentPage < totalPages - 1) { + Icon(Icons.Default.ChevronRight, contentDescription = "Вперед") + } + } + } +} + +// ========================================== +// 6. ДИАЛОГИ +// ========================================== + +@Composable +fun EditDialog(item: AnswerGet, onDismiss: () -> Unit, onSave: (Int, String, String) -> Unit) { + var name by remember { mutableStateOf(item.name ?: "") } + var des by remember { mutableStateOf(item.des ?: "") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Редактировать") }, + text = { + Column { + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Название") }) + Spacer(Modifier.height(8.dp)) + OutlinedTextField(value = des, onValueChange = { des = it }, label = { Text("Описание") }) + } + }, + confirmButton = { Button(onClick = { onSave(item.id, name, des) }) { Text("Сохранить") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } } + ) +} + +@Composable +fun UploadDialog(tab: AppTab, onDismiss: () -> Unit, onUpload: (ByteArray, String, String, String) -> Unit) { + val context = LocalContext.current + var name by remember { mutableStateOf("") } + var des by remember { mutableStateOf("") } + var fileBytes by remember { mutableStateOf(null) } + var fileName by remember { mutableStateOf("") } + + val mime = when (tab) { + AppTab.IMAGES -> "image/*" + AppTab.AUDIO -> "audio/*" + AppTab.VIDEO -> "video/*" + else -> "*/*" + } + + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + uri?.let { + val stream: InputStream? = context.contentResolver.openInputStream(it) + fileBytes = stream?.readBytes() + fileName = "file_${System.currentTimeMillis()}" + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Загрузка: ${tab.title}") }, + text = { + Column { + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Название") }) + Spacer(Modifier.height(8.dp)) + OutlinedTextField(value = des, onValueChange = { des = it }, label = { Text("Описание") }) + Spacer(Modifier.height(8.dp)) + Button(onClick = { launcher.launch(mime) }) { + Text(if (fileBytes != null) "Файл выбран" else "Выбрать файл") + } + } + }, + confirmButton = { + Button( + enabled = fileBytes != null, + onClick = { fileBytes?.let { onUpload(it, fileName, name, des) } } + ) { Text("Загрузить") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/sergay/hhu67/pw/app/ui/theme/Color.kt b/app/src/main/java/com/sergay/hhu67/pw/app/ui/theme/Color.kt new file mode 100644 index 0000000..ca486f5 --- /dev/null +++ b/app/src/main/java/com/sergay/hhu67/pw/app/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.sergay.hhu67.pw.app.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/sergay/hhu67/pw/app/ui/theme/Theme.kt b/app/src/main/java/com/sergay/hhu67/pw/app/ui/theme/Theme.kt new file mode 100644 index 0000000..4416445 --- /dev/null +++ b/app/src/main/java/com/sergay/hhu67/pw/app/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.sergay.hhu67.pw.app.ui.theme + +import android.app.Activity +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 DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun MyApplicationTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/sergay/hhu67/pw/app/ui/theme/Type.kt b/app/src/main/java/com/sergay/hhu67/pw/app/ui/theme/Type.kt new file mode 100644 index 0000000..d1835bc --- /dev/null +++ b/app/src/main/java/com/sergay/hhu67/pw/app/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.sergay.hhu67.pw.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/keepRules/rules.keep b/app/src/main/keepRules/rules.keep new file mode 100644 index 0000000..d7e081a --- /dev/null +++ b/app/src/main/keepRules/rules.keep @@ -0,0 +1,12 @@ +# Add project specific R8 rules here. +# AGP will combine all keep rule files in src/main/keepRules to pass to R8 +# +# For more details, see +# https://d.android.com/r/tools/r8/keep-rules + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file 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..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file 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..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3de1a39 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + My Application + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..e48770a --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +