minor changes

This commit is contained in:
hhu67 2026-08-20 00:38:35 +03:00
parent aeb51fdb3a
commit 7387bedf9e
22 changed files with 363 additions and 342 deletions

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

View file

@ -1,5 +1,7 @@
package com.sergay.hhu67.pw.app package com.sergay.hhu67.pw.app
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
@ -16,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -28,7 +31,10 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.edit
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@ -40,6 +46,7 @@ import coil.decode.VideoFrameDecoder
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.* import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Response import retrofit2.Response
@ -96,17 +103,36 @@ data class AnswerBotList(
@SerializedName("items") val items: List<AnswerBot>? @SerializedName("items") val items: List<AnswerBot>?
) )
class AppCookieJar : CookieJar { class PersistentCookieJar(context: Context) : CookieJar {
private val prefs: SharedPreferences = context.getSharedPreferences("app_cookies", Context.MODE_PRIVATE)
private val cookieStore = mutableListOf<Cookie>() private val cookieStore = mutableListOf<Cookie>()
init {
val savedToken = prefs.getString("token_cookie", null)
if (!savedToken.isNullOrEmpty()) {
Cookie.parse("https://sergay.hhu67.pw/".toHttpUrl(), savedToken)?.let {
cookieStore.add(it)
}
}
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) { override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
cookies.firstOrNull { it.name == "token" }?.let {
prefs.edit { putString("token_cookie", it.toString()) }
}
cookieStore.removeAll { existing -> cookies.any { it.name == existing.name } } cookieStore.removeAll { existing -> cookies.any { it.name == existing.name } }
cookieStore.addAll(cookies) cookieStore.addAll(cookies)
} }
override fun loadForRequest(url: HttpUrl): List<Cookie> = cookieStore override fun loadForRequest(url: HttpUrl): List<Cookie> = cookieStore
fun clear() = cookieStore.clear()
fun clear() {
cookieStore.clear()
prefs.edit { clear() }
}
} }
val cookieJar = AppCookieJar() lateinit var globalCookieJar: PersistentCookieJar
interface IpService { interface IpService {
@GET("/") @GET("/")
@ -191,7 +217,7 @@ interface ApiService {
companion object { companion object {
fun create(): ApiService { fun create(): ApiService {
val okHttp = OkHttpClient.Builder() val okHttp = OkHttpClient.Builder()
.cookieJar(cookieJar) .cookieJar(globalCookieJar)
.connectTimeout(60, TimeUnit.SECONDS) .connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS)
.build() .build()
@ -219,7 +245,7 @@ interface ApiService {
// ========================================== // ==========================================
enum class AppTab(val title: String) { enum class AppTab(val title: String) {
IMAGES("Картинки"), IMAGES("Фото"),
AUDIO("Аудио"), AUDIO("Аудио"),
VIDEO("Видео"), VIDEO("Видео"),
ADMIN("Админка") ADMIN("Админка")
@ -231,39 +257,57 @@ sealed interface ContentState {
data class Error(val message: String) : ContentState data class Error(val message: String) : ContentState
} }
class MainViewModel : ViewModel() { class MainViewModel(appContext: Context) : ViewModel() {
private val authPrefs: SharedPreferences = appContext.getSharedPreferences("auth_session", Context.MODE_PRIVATE)
private val api = ApiService.create() private val api = ApiService.create()
private val ipApi = ApiService.createIpService() private val ipApi = ApiService.createIpService()
var isAuthenticated by mutableStateOf(false) var isAuthenticated by mutableStateOf(authPrefs.getBoolean("is_auth", false))
var isAdmin by mutableStateOf(false) var isAdmin by mutableStateOf(authPrefs.getBoolean("is_admin", false))
var isAuthLoading by mutableStateOf(false) var isAuthLoading by mutableStateOf(false)
var currentTab by mutableStateOf(AppTab.IMAGES) var currentTab by mutableStateOf(AppTab.IMAGES)
var state by mutableStateOf<ContentState>(ContentState.Loading) var state by mutableStateOf<ContentState>(ContentState.Loading)
// Пагинация для медиа (Картинки: 20, Аудио: 20, Видео: 10) var currentPage by mutableIntStateOf(0)
var currentPage by mutableStateOf(0) var totalItemsCount by mutableIntStateOf(0)
var totalItemsCount by mutableStateOf(0)
// Модальное окно просмотра Фото / Видео
var previewMediaItem by mutableStateOf<AnswerGet?>(null) var previewMediaItem by mutableStateOf<AnswerGet?>(null)
var isPreviewVideo by mutableStateOf(false) var isPreviewVideo by mutableStateOf(false)
// Аудиоплеер (Нижняя строка)
var audioPlaylist by mutableStateOf<List<AnswerGet>>(emptyList()) var audioPlaylist by mutableStateOf<List<AnswerGet>>(emptyList())
var currentAudioIndex by mutableStateOf(-1) var currentAudioIndex by mutableIntStateOf(-1)
var isAudioPlaying by mutableStateOf(false) var isAudioPlaying by mutableStateOf(false)
// Админка: юзеры
var adminUsers by mutableStateOf<List<AnswerAdmin>>(emptyList()) var adminUsers by mutableStateOf<List<AnswerAdmin>>(emptyList())
var usersTotalCount by mutableStateOf(0) var usersTotalCount by mutableIntStateOf(0)
var usersCurrentPage by mutableStateOf(0) var usersCurrentPage by mutableIntStateOf(0)
// Админка: фразы бота
var adminQuotes by mutableStateOf<List<AnswerBot>>(emptyList()) var adminQuotes by mutableStateOf<List<AnswerBot>>(emptyList())
var quotesTotalCount by mutableStateOf(0) var quotesTotalCount by mutableIntStateOf(0)
var quotesCurrentPage by mutableStateOf(0) var quotesCurrentPage by mutableIntStateOf(0)
init {
if (isAuthenticated) {
checkRoleAndRestore()
}
}
private fun checkRoleAndRestore() {
viewModelScope.launch {
try {
api.getUsers(limit = 1, offset = 0)
isAdmin = true
authPrefs.edit { putBoolean("is_admin", true) }
} catch (e: retrofit2.HttpException) {
if (e.code() == 403) {
isAdmin = false
authPrefs.edit { putBoolean("is_admin", false) }
}
} catch (_: Exception) {}
selectTab(AppTab.IMAGES)
}
}
fun login(user: String, pass: String, onResult: (Boolean, String) -> Unit) { fun login(user: String, pass: String, onResult: (Boolean, String) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
@ -285,12 +329,16 @@ class MainViewModel : ViewModel() {
if (resp.isSuccessful) { if (resp.isSuccessful) {
isAuthenticated = true isAuthenticated = true
authPrefs.edit { putBoolean("is_auth", true) }
try { try {
api.getUsers(limit = 1, offset = 0) api.getUsers(limit = 1, offset = 0)
isAdmin = true isAdmin = true
authPrefs.edit { putBoolean("is_admin", true) }
onResult(true, "Вход выполнен: Администратор") onResult(true, "Вход выполнен: Администратор")
} catch (e: retrofit2.HttpException) { } catch (e: retrofit2.HttpException) {
isAdmin = false isAdmin = false
authPrefs.edit { putBoolean("is_admin", false) }
onResult(true, "Вход выполнен: Пользователь") onResult(true, "Вход выполнен: Пользователь")
} }
selectTab(AppTab.IMAGES) selectTab(AppTab.IMAGES)
@ -306,12 +354,12 @@ class MainViewModel : ViewModel() {
} }
fun logout() { fun logout() {
cookieJar.clear() globalCookieJar.clear()
authPrefs.edit { clear() }
isAuthenticated = false isAuthenticated = false
isAdmin = false isAdmin = false
currentTab = AppTab.IMAGES currentTab = AppTab.IMAGES
currentAudioIndex = -1 dismissAudio()
isAudioPlaying = false
currentPage = 0 currentPage = 0
} }
@ -334,15 +382,13 @@ class MainViewModel : ViewModel() {
when (currentTab) { when (currentTab) {
AppTab.IMAGES -> { AppTab.IMAGES -> {
val limit = 20 val limit = 20
val offset = page * limit val res = api.getImages(limit = limit, offset = page * limit)
val res = api.getImages(limit = limit, offset = offset)
totalItemsCount = res.total totalItemsCount = res.total
state = ContentState.SuccessItems(res.items ?: emptyList()) state = ContentState.SuccessItems(res.items ?: emptyList())
} }
AppTab.AUDIO -> { AppTab.AUDIO -> {
val limit = 20 val limit = 20
val offset = page * limit val res = api.getAudio(limit = limit, offset = page * limit)
val res = api.getAudio(limit = limit, offset = offset)
totalItemsCount = res.total totalItemsCount = res.total
val list = res.items ?: emptyList() val list = res.items ?: emptyList()
audioPlaylist = list audioPlaylist = list
@ -350,8 +396,7 @@ class MainViewModel : ViewModel() {
} }
AppTab.VIDEO -> { AppTab.VIDEO -> {
val limit = 10 val limit = 10
val offset = page * limit val res = api.getVideo(limit = limit, offset = page * limit)
val res = api.getVideo(limit = limit, offset = offset)
totalItemsCount = res.total totalItemsCount = res.total
state = ContentState.SuccessItems(res.items ?: emptyList()) state = ContentState.SuccessItems(res.items ?: emptyList())
} }
@ -366,8 +411,7 @@ class MainViewModel : ViewModel() {
fun loadAdminUsers(page: Int) { fun loadAdminUsers(page: Int) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val offset = page * 20 val res = api.getUsers(limit = 20, offset = page * 20)
val res = api.getUsers(limit = 20, offset = offset)
adminUsers = res.items ?: emptyList() adminUsers = res.items ?: emptyList()
usersTotalCount = res.total usersTotalCount = res.total
usersCurrentPage = page usersCurrentPage = page
@ -378,8 +422,7 @@ class MainViewModel : ViewModel() {
fun loadAdminQuotes(page: Int) { fun loadAdminQuotes(page: Int) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val offset = page * 20 val res = api.getBotQuotes(limit = 20, offset = page * 20)
val res = api.getBotQuotes(limit = 20, offset = offset)
adminQuotes = res.items ?: emptyList() adminQuotes = res.items ?: emptyList()
quotesTotalCount = res.total quotesTotalCount = res.total
quotesCurrentPage = page quotesCurrentPage = page
@ -394,6 +437,11 @@ class MainViewModel : ViewModel() {
} }
} }
fun dismissAudio() {
currentAudioIndex = -1
isAudioPlaying = false
}
fun nextAudio() { fun nextAudio() {
if (audioPlaylist.isNotEmpty()) { if (audioPlaylist.isNotEmpty()) {
currentAudioIndex = (currentAudioIndex + 1) % audioPlaylist.size currentAudioIndex = (currentAudioIndex + 1) % audioPlaylist.size
@ -487,13 +535,25 @@ class MainViewModel : ViewModel() {
} }
} }
class MainViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return MainViewModel(context) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
// ========================================== // ==========================================
// 3. ЭКРАНЫ // 3. UI И ЭКРАНЫ
// ========================================== // ==========================================
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
globalCookieJar = PersistentCookieJar(applicationContext)
setContent { setContent {
MaterialTheme(colorScheme = darkColorScheme( MaterialTheme(colorScheme = darkColorScheme(
primary = Color(0xFFBB86FC), primary = Color(0xFFBB86FC),
@ -501,7 +561,7 @@ class MainActivity : ComponentActivity() {
background = Color(0xFF121212) background = Color(0xFF121212)
)) { )) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
val vm: MainViewModel = viewModel() val vm: MainViewModel = viewModel(factory = MainViewModelFactory(applicationContext))
if (!vm.isAuthenticated) { if (!vm.isAuthenticated) {
AuthScreen(vm = vm) AuthScreen(vm = vm)
} else { } else {
@ -522,7 +582,7 @@ fun AuthScreen(vm: MainViewModel) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Авторизация / Регистрация", style = MaterialTheme.typography.titleLarge) Text("Авторизация", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
@ -576,7 +636,6 @@ fun MainAppScreen(vm: MainViewModel) {
val exoPlayer = remember { ExoPlayer.Builder(ctx).build() } val exoPlayer = remember { ExoPlayer.Builder(ctx).build() }
// Настройка Coil для превью кадров из видео
val imageLoader = remember { val imageLoader = remember {
ImageLoader.Builder(ctx) ImageLoader.Builder(ctx)
.components { .components {
@ -596,6 +655,8 @@ fun MainAppScreen(vm: MainViewModel) {
} }
if (vm.isAudioPlaying) exoPlayer.play() else exoPlayer.pause() if (vm.isAudioPlaying) exoPlayer.play() else exoPlayer.pause()
} }
} else {
exoPlayer.stop()
} }
} }
@ -609,7 +670,7 @@ fun MainAppScreen(vm: MainViewModel) {
title = { Text(vm.currentTab.title) }, title = { Text(vm.currentTab.title) },
actions = { actions = {
IconButton(onClick = { vm.logout() }) { IconButton(onClick = { vm.logout() }) {
Icon(Icons.Default.Logout, contentDescription = "Выйти") Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Выйти")
} }
IconButton(onClick = { IconButton(onClick = {
if (vm.currentTab == AppTab.ADMIN) { if (vm.currentTab == AppTab.ADMIN) {
@ -635,62 +696,92 @@ fun MainAppScreen(vm: MainViewModel) {
Column { Column {
if (vm.currentAudioIndex in vm.audioPlaylist.indices) { if (vm.currentAudioIndex in vm.audioPlaylist.indices) {
val currentTrack = vm.audioPlaylist[vm.currentAudioIndex] val currentTrack = vm.audioPlaylist[vm.currentAudioIndex]
Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { val dismissState = rememberSwipeToDismissBoxState(
Row( confirmValueChange = { value ->
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), if (value != SwipeToDismissBoxValue.Settled) {
verticalAlignment = Alignment.CenterVertically vm.dismissAudio()
) { true
Column(modifier = Modifier.weight(1f)) { } else false
Text( }
text = currentTrack.name ?: "Аудиозапись", )
style = MaterialTheme.typography.bodyMedium,
maxLines = 1 SwipeToDismissBox(
) state = dismissState,
Text( backgroundContent = {
text = currentTrack.des ?: "", Box(
style = MaterialTheme.typography.labelSmall, modifier = Modifier
color = Color.LightGray, .fillMaxSize()
maxLines = 1 .background(MaterialTheme.colorScheme.errorContainer)
) .padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Close, contentDescription = "Закрыть", tint = MaterialTheme.colorScheme.onErrorContainer)
} }
IconButton(onClick = { vm.prevAudio() }) { }
Icon(Icons.Default.SkipPrevious, contentDescription = "Назад") ) {
} Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = { vm.isAudioPlaying = !vm.isAudioPlaying }) { Row(
Icon(if (vm.isAudioPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, null) modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
} verticalAlignment = Alignment.CenterVertically
IconButton(onClick = { vm.nextAudio() }) { ) {
Icon(Icons.Default.SkipNext, contentDescription = "Вперед") 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() }, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.SkipPrevious, contentDescription = "Назад")
}
IconButton(onClick = { vm.isAudioPlaying = !vm.isAudioPlaying }, modifier = Modifier.size(36.dp)) {
Icon(if (vm.isAudioPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, null)
}
IconButton(onClick = { vm.nextAudio() }, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.SkipNext, contentDescription = "Вперед")
}
IconButton(onClick = { vm.dismissAudio() }, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Close, contentDescription = "Закрыть плеер")
}
} }
} }
} }
} }
NavigationBar { NavigationBar(
modifier = Modifier.height(64.dp),
tonalElevation = 4.dp
) {
NavigationBarItem( NavigationBarItem(
selected = vm.currentTab == AppTab.IMAGES, selected = vm.currentTab == AppTab.IMAGES,
onClick = { vm.selectTab(AppTab.IMAGES) }, onClick = { vm.selectTab(AppTab.IMAGES) },
icon = { Icon(Icons.Default.Image, null) }, icon = { Icon(Icons.Default.Image, null, modifier = Modifier.size(20.dp)) },
label = { Text("Фото") } label = { Text("Фото", style = MaterialTheme.typography.labelSmall) }
) )
NavigationBarItem( NavigationBarItem(
selected = vm.currentTab == AppTab.AUDIO, selected = vm.currentTab == AppTab.AUDIO,
onClick = { vm.selectTab(AppTab.AUDIO) }, onClick = { vm.selectTab(AppTab.AUDIO) },
icon = { Icon(Icons.Default.Audiotrack, null) }, icon = { Icon(Icons.Default.Audiotrack, null, modifier = Modifier.size(20.dp)) },
label = { Text("Аудио") } label = { Text("Аудио", style = MaterialTheme.typography.labelSmall) }
) )
NavigationBarItem( NavigationBarItem(
selected = vm.currentTab == AppTab.VIDEO, selected = vm.currentTab == AppTab.VIDEO,
onClick = { vm.selectTab(AppTab.VIDEO) }, onClick = { vm.selectTab(AppTab.VIDEO) },
icon = { Icon(Icons.Default.Videocam, null) }, icon = { Icon(Icons.Default.Videocam, null, modifier = Modifier.size(20.dp)) },
label = { Text("Видео") } label = { Text("Видео", style = MaterialTheme.typography.labelSmall) }
) )
if (vm.isAdmin) { if (vm.isAdmin) {
NavigationBarItem( NavigationBarItem(
selected = vm.currentTab == AppTab.ADMIN, selected = vm.currentTab == AppTab.ADMIN,
onClick = { vm.selectTab(AppTab.ADMIN) }, onClick = { vm.selectTab(AppTab.ADMIN) },
icon = { Icon(Icons.Default.AdminPanelSettings, null) }, icon = { Icon(Icons.Default.AdminPanelSettings, null, modifier = Modifier.size(20.dp)) },
label = { Text("Админка") } label = { Text("Админка", style = MaterialTheme.typography.labelSmall) }
) )
} }
} }
@ -717,43 +808,65 @@ fun MainAppScreen(vm: MainViewModel) {
is ContentState.SuccessItems -> { is ContentState.SuccessItems -> {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
if (vm.currentTab == AppTab.IMAGES) { when (vm.currentTab) {
LazyVerticalGrid( AppTab.IMAGES -> {
columns = GridCells.Fixed(2), LazyVerticalGrid(
contentPadding = PaddingValues(8.dp), columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp),
) { verticalArrangement = Arrangement.spacedBy(8.dp)
items(s.items, key = { it.id }) { item -> ) {
ImageGridItem(item = item, onClick = { items(s.items, key = { it.id }) { item ->
vm.previewMediaItem = item GridMediaItem(
vm.isPreviewVideo = false item = item,
}) isVideo = false,
imageLoader = imageLoader,
onClick = {
vm.previewMediaItem = item
vm.isPreviewVideo = false
}
)
}
} }
} }
} else { AppTab.VIDEO -> {
LazyColumn( LazyVerticalGrid(
contentPadding = PaddingValues(16.dp), columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(16.dp) contentPadding = PaddingValues(8.dp),
) { horizontalArrangement = Arrangement.spacedBy(8.dp),
items(s.items.size) { index -> verticalArrangement = Arrangement.spacedBy(8.dp)
val item = s.items[index] ) {
MediaListItem( items(s.items, key = { it.id }) { item ->
item = item, GridMediaItem(
tab = vm.currentTab, item = item,
imageLoader = imageLoader, isVideo = true,
onPlayAudio = { vm.playAudioTrack(index) }, imageLoader = imageLoader,
onOpenVideo = { onClick = {
vm.previewMediaItem = item vm.previewMediaItem = item
vm.isPreviewVideo = true vm.isPreviewVideo = true
} }
) )
}
} }
} }
AppTab.AUDIO -> {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(s.items.size) { index ->
val item = s.items[index]
AudioListItem(
item = item,
onPlayAudio = { vm.playAudioTrack(index) }
)
}
}
}
AppTab.ADMIN -> {}
} }
} }
// Пагинатор для медиа
PaginationControls( PaginationControls(
currentPage = vm.currentPage, currentPage = vm.currentPage,
totalPages = totalPages, totalPages = totalPages,
@ -766,10 +879,17 @@ fun MainAppScreen(vm: MainViewModel) {
} }
} }
// Модальное окно полного просмотра
vm.previewMediaItem?.let { item -> vm.previewMediaItem?.let { item ->
Dialog(onDismissRequest = { vm.previewMediaItem = null }) { Dialog(
Card(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { onDismissRequest = { vm.previewMediaItem = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
modifier = Modifier
.fillMaxWidth(0.95f)
.wrapContentHeight()
.padding(8.dp)
) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
if (vm.isPreviewVideo && !item.link.isNullOrEmpty()) { if (vm.isPreviewVideo && !item.link.isNullOrEmpty()) {
VideoPlayerView(url = item.link) VideoPlayerView(url = item.link)
@ -777,10 +897,13 @@ fun MainAppScreen(vm: MainViewModel) {
AsyncImage( AsyncImage(
model = item.link, model = item.link,
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxWidth().height(260.dp), modifier = Modifier
.fillMaxWidth()
.height(320.dp),
contentScale = ContentScale.Fit contentScale = ContentScale.Fit
) )
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text(text = item.name ?: "Без названия", style = MaterialTheme.typography.titleLarge) Text(text = item.name ?: "Без названия", style = MaterialTheme.typography.titleLarge)
if (!item.des.isNullOrBlank()) { if (!item.des.isNullOrBlank()) {
@ -836,25 +959,49 @@ fun MainAppScreen(vm: MainViewModel) {
} }
// ========================================== // ==========================================
// 4. ЭЛЕМЕНТЫ СПИСКОВ И СЕТКИ // 4. КОМПОНЕНТЫ
// ========================================== // ==========================================
@Composable @Composable
fun ImageGridItem(item: AnswerGet, onClick: () -> Unit) { fun GridMediaItem(
item: AnswerGet,
isVideo: Boolean,
imageLoader: ImageLoader,
onClick: () -> Unit
) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .clickable { onClick() }
) { ) {
Column { Column {
AsyncImage( Box(
model = item.link,
contentDescription = item.name,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(160.dp), .height(150.dp)
contentScale = ContentScale.Crop ) {
) AsyncImage(
model = item.link,
imageLoader = imageLoader,
contentDescription = item.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
if (isVideo) {
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(36.dp).padding(6.dp)
)
}
}
}
Column(modifier = Modifier.padding(8.dp)) { Column(modifier = Modifier.padding(8.dp)) {
Text( Text(
text = item.name ?: "Без названия", text = item.name ?: "Без названия",
@ -875,64 +1022,26 @@ fun ImageGridItem(item: AnswerGet, onClick: () -> Unit) {
} }
@Composable @Composable
fun MediaListItem( fun AudioListItem(
item: AnswerGet, item: AnswerGet,
tab: AppTab, onPlayAudio: () -> Unit
imageLoader: ImageLoader,
onPlayAudio: () -> Unit,
onOpenVideo: () -> Unit
) { ) {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier.padding(16.dp),
.clickable { verticalAlignment = Alignment.CenterVertically
if (tab == AppTab.VIDEO) onOpenVideo() ) {
} Column(modifier = Modifier.weight(1f)) {
) {
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) Text(text = item.name ?: "Без названия", style = MaterialTheme.typography.titleMedium)
if (!item.des.isNullOrBlank()) { if (!item.des.isNullOrBlank()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text(text = item.des, style = MaterialTheme.typography.bodyMedium, color = Color.LightGray) Text(text = item.des, style = MaterialTheme.typography.bodyMedium, color = Color.LightGray)
} }
Spacer(Modifier.height(8.dp)) }
if (tab == AppTab.AUDIO) { Button(onClick = onPlayAudio) {
Button(onClick = onPlayAudio) { Icon(Icons.Default.PlayArrow, null)
Icon(Icons.Default.PlayArrow, null) Spacer(Modifier.width(4.dp))
Spacer(Modifier.width(4.dp)) Text("Включить")
Text("Включить")
}
}
} }
} }
} }
@ -960,7 +1069,9 @@ fun VideoPlayerView(url: String) {
useController = true useController = true
} }
}, },
modifier = Modifier.fillMaxWidth().height(230.dp) modifier = Modifier
.fillMaxWidth()
.height(420.dp)
) )
} }
@ -970,11 +1081,11 @@ fun VideoPlayerView(url: String) {
@Composable @Composable
fun AdminDashboard(vm: MainViewModel) { fun AdminDashboard(vm: MainViewModel) {
var adminTab by remember { mutableStateOf(0) } var adminTab by remember { mutableIntStateOf(0) }
var editQuoteItem by remember { mutableStateOf<AnswerBot?>(null) } var editQuoteItem by remember { mutableStateOf<AnswerBot?>(null) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = adminTab) { SecondaryTabRow(selectedTabIndex = adminTab) {
Tab(selected = adminTab == 0, onClick = { adminTab = 0 }, text = { Text("Юзеры (${vm.usersTotalCount})") }) Tab(selected = adminTab == 0, onClick = { adminTab = 0 }, text = { Text("Юзеры (${vm.usersTotalCount})") })
Tab(selected = adminTab == 1, onClick = { adminTab = 1 }, text = { Text("Фразы бота (${vm.quotesTotalCount})") }) Tab(selected = adminTab == 1, onClick = { adminTab = 1 }, text = { Text("Фразы бота (${vm.quotesTotalCount})") })
} }

View file

@ -1,170 +1,74 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector
android:width="108dp"
android:height="108dp" android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> xmlns:android="http://schemas.android.com/apk/res/android">
<path <path android:fillColor="#3DDC84"
android:fillColor="#3DDC84" android:pathData="M0,0h108v108h-108z"/>
android:pathData="M0,0h108v108h-108z" /> <path android:fillColor="#00000000" android:pathData="M9,0L9,108"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:pathData="M9,0L9,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M19,0L19,108" <path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M59,0L59,108"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:pathData="M29,0L29,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M39,0L39,108" <path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M0,9L108,9"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:pathData="M49,0L49,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M59,0L59,108" <path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M0,59L108,59"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:pathData="M69,0L69,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M79,0L79,108" <path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M19,29L89,29"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:pathData="M89,0L89,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M99,0L99,108" <path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M19,79L89,79"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:pathData="M0,9L108,9" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M0,19L108,19" <path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M69,19L69,89"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:pathData="M0,29L108,29" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector> </vector>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">My Application</string> <string name="app_name">Cергулек</string>
</resources> </resources>