diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1572950..3d75cd0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "ru.mailib.ttrpg.licensemanager" minSdk = 26 targetSdk = 35 - versionCode = 3 - versionName = "1.0.2" + versionCode = 4 + versionName = "1.1.0" } buildTypes { diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/MainActivity.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/MainActivity.kt index 4f9d3f8..0ddf2f7 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/MainActivity.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api @@ -36,6 +37,7 @@ import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel import ru.mailib.ttrpg.licensemanager.ui.screens.GenerateKeyScreen import ru.mailib.ttrpg.licensemanager.ui.screens.LicenseListScreen import ru.mailib.ttrpg.licensemanager.ui.screens.SettingsScreen +import ru.mailib.ttrpg.licensemanager.ui.screens.StatsScreen class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -52,6 +54,7 @@ class MainActivity : ComponentActivity() { private object Routes { const val LIST = "list" const val GENERATE = "generate" + const val STATS = "stats" const val SETTINGS = "settings" } @@ -62,7 +65,7 @@ fun LicenseManagerApp(viewModel: LicenseViewModel = viewModel()) { val backStack by navController.currentBackStackEntryAsState() val currentRoute = backStack?.destination?.route - val topLevelRoutes = setOf(Routes.LIST, Routes.GENERATE) + val topLevelRoutes = setOf(Routes.LIST, Routes.GENERATE, Routes.STATS) val showBottomBar = currentRoute in topLevelRoutes Scaffold( @@ -99,6 +102,12 @@ fun LicenseManagerApp(viewModel: LicenseViewModel = viewModel()) { icon = { Icon(Icons.Default.Add, contentDescription = null) }, label = { Text(stringResource(R.string.tab_generate)) }, ) + NavigationBarItem( + selected = currentRoute == Routes.STATS, + onClick = { navController.navigate(Routes.STATS) }, + icon = { Icon(Icons.Default.BarChart, contentDescription = null) }, + label = { Text(stringResource(R.string.tab_stats)) }, + ) } } }, @@ -114,6 +123,9 @@ fun LicenseManagerApp(viewModel: LicenseViewModel = viewModel()) { composable(Routes.GENERATE) { GenerateKeyScreen(viewModel) } + composable(Routes.STATS) { + StatsScreen(viewModel) + } composable(Routes.SETTINGS) { SettingsScreen( viewModel = viewModel, diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt index ac720d3..e34a4dc 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt @@ -3,14 +3,21 @@ package ru.mailib.ttrpg.licensemanager.data import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Query interface LicenseApi { @GET("v1/admin/licenses") suspend fun listLicenses(): LicensesResponse @POST("v1/admin/product-keys") - suspend fun createProductKey(@Body body: CreateProductKeyRequest): ProductKeyResponse + suspend fun createProductKey(@Body body: CreateProductKeyRequest): CreateProductKeyApiResponse - @POST("v1/admin/revoke") - suspend fun revokeLicense(@Body body: RevokeLicenseRequest): RevokeLicenseResponse + @POST("v1/admin/product-keys/delete") + suspend fun deleteProductKey(@Body body: DeleteProductKeyRequest): DeleteProductKeyResponse + + @GET("v1/admin/stats/downloads") + suspend fun getDownloadStats(): DownloadStatsOverview + + @GET("v1/admin/stats/downloads") + suspend fun getDownloadStatsForMonth(@Query("month") month: String): DownloadStatsMonth } diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt index 1a098d0..2812924 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt @@ -7,6 +7,7 @@ data class LicenseEntry( val sub: String, val pid: String, val maxDevices: Int, + val createdAtSec: Long? = null, val expiryMode: String? = null, val expiresAtSec: Long? = null, val validDays: Int? = null, @@ -27,6 +28,7 @@ data class CreateProductKeyRequest( val maxDevices: Int, val expiresAtSec: Long? = null, val validDays: Int? = null, + val count: Int? = null, ) data class ProductKeyResponse( @@ -38,12 +40,54 @@ data class ProductKeyResponse( val validDays: Int? = null, ) -data class RevokeLicenseRequest( +data class CreateProductKeyApiResponse( + val key: String? = null, + val sub: String? = null, + val pid: String? = null, + val maxDevices: Int? = null, + val expiresAtSec: Long? = null, + val validDays: Int? = null, + val keys: List? = null, + val count: Int? = null, +) { + fun asSingle(): ProductKeyResponse? { + if (key == null || sub == null || pid == null || maxDevices == null) return null + return ProductKeyResponse( + key = key, + sub = sub, + pid = pid, + maxDevices = maxDevices, + expiresAtSec = expiresAtSec, + validDays = validDays, + ) + } +} + +sealed class CreateProductKeyResult { + data class Single(val key: ProductKeyResponse) : CreateProductKeyResult() + data class Bulk(val keys: List) : CreateProductKeyResult() +} + +data class DeleteProductKeyRequest( val key: String, ) -data class RevokeLicenseResponse( +data class DeleteProductKeyResponse( val ok: Boolean, + val deleted: String? = null, +) + +data class DownloadStatsMonth( + val month: String, + val windows: Int, + val macos: Int, + val linux: Int, + val total: Int, +) + +data class DownloadStatsOverview( + val months: List, + val currentMonth: DownloadStatsMonth, ) data class ApiErrorBody( diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt index 479a7ca..9f5e2d0 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt @@ -55,12 +55,31 @@ class LicenseRepository( suspend fun createProductKey( settings: AppSettings, request: CreateProductKeyRequest, - ): Result = runCatching { - createApi(settings).createProductKey(request) + ): Result = runCatching { + val response = createApi(settings).createProductKey(request) + val bulkKeys = response.keys + if (!bulkKeys.isNullOrEmpty()) { + CreateProductKeyResult.Bulk(bulkKeys) + } else { + val single = response.asSingle() + ?: error("Некорректный ответ сервера при создании ключа") + CreateProductKeyResult.Single(single) + } } - suspend fun revokeLicense(settings: AppSettings, key: String): Result = runCatching { - createApi(settings).revokeLicense(RevokeLicenseRequest(key)) + suspend fun deleteLicense(settings: AppSettings, key: String): Result = runCatching { + createApi(settings).deleteProductKey(DeleteProductKeyRequest(key)) + } + + suspend fun getDownloadStatsOverview(settings: AppSettings): Result = runCatching { + createApi(settings).getDownloadStats() + } + + suspend fun getDownloadStatsForMonth( + settings: AppSettings, + month: String, + ): Result = runCatching { + createApi(settings).getDownloadStatsForMonth(month) } private fun createApi(settings: AppSettings): LicenseApi { diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt index 0542a0f..c3d953a 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ru.mailib.ttrpg.licensemanager.data.AppSettings import ru.mailib.ttrpg.licensemanager.data.CreateProductKeyRequest +import ru.mailib.ttrpg.licensemanager.data.CreateProductKeyResult +import ru.mailib.ttrpg.licensemanager.data.DownloadStatsMonth import ru.mailib.ttrpg.licensemanager.data.LicenseEntry import ru.mailib.ttrpg.licensemanager.data.LicenseRepository import ru.mailib.ttrpg.licensemanager.data.ProductKeyResponse @@ -39,19 +41,29 @@ val LICENSE_TIER_PRESETS = listOf( data class LicenseListUiState( val licenses: List = emptyList(), val isLoading: Boolean = false, - val revokingKey: String? = null, + val deletingKey: String? = null, val error: String? = null, ) data class GenerateKeyUiState( val pid: String = "dnd_player", val maxDevices: String = "3", + val count: String = "1", val expiryMode: ExpiryInputMode = ExpiryInputMode.PERIOD, val validDays: String = "365", val expiryDate: LocalDate = LocalDate.of(2027, 12, 31), val isSubmitting: Boolean = false, val error: String? = null, val createdKey: ProductKeyResponse? = null, + val createdKeys: List = emptyList(), +) + +data class StatsUiState( + val months: List = emptyList(), + val selectedMonth: String? = null, + val stats: DownloadStatsMonth? = null, + val isLoading: Boolean = false, + val error: String? = null, ) data class SettingsUiState( @@ -71,6 +83,9 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) private val _generateState = MutableStateFlow(GenerateKeyUiState()) val generateState: StateFlow = _generateState.asStateFlow() + private val _statsState = MutableStateFlow(StatsUiState()) + val statsState: StateFlow = _statsState.asStateFlow() + private val _settingsState = MutableStateFlow(SettingsUiState()) val settingsState: StateFlow = _settingsState.asStateFlow() @@ -95,19 +110,19 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) } } - fun revokeLicense(key: String) { + fun deleteLicense(key: String) { viewModelScope.launch { - _listState.update { it.copy(revokingKey = key, error = null) } - licenseRepository.revokeLicense(currentSettings(), key) + _listState.update { it.copy(deletingKey = key, error = null) } + licenseRepository.deleteLicense(currentSettings(), key) .onSuccess { loadLicenses() - _listState.update { it.copy(revokingKey = null) } + _listState.update { it.copy(deletingKey = null) } } .onFailure { e -> _listState.update { it.copy( - revokingKey = null, - error = e.message ?: "Не удалось отозвать лицензию", + deletingKey = null, + error = e.message ?: "Не удалось удалить ключ", ) } } @@ -122,6 +137,10 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) _generateState.update { it.copy(maxDevices = value.filter { ch -> ch.isDigit() }.take(2), error = null) } } + fun updateCount(value: String) { + _generateState.update { it.copy(count = value.filter { ch -> ch.isDigit() }.take(3), error = null) } + } + fun updateExpiryMode(mode: ExpiryInputMode) { _generateState.update { it.copy(expiryMode = mode, error = null) } } @@ -145,8 +164,8 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) _generateState.update { it.copy(expiryDate = date, error = null) } } - fun clearCreatedKey() { - _generateState.update { it.copy(createdKey = null) } + fun clearCreatedKeys() { + _generateState.update { it.copy(createdKey = null, createdKeys = emptyList()) } } fun generateKey() { @@ -156,6 +175,11 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) _generateState.update { it.copy(error = "Укажите число устройств (минимум 1)") } return } + val count = state.count.toIntOrNull() + if (count == null || count < 1 || count > 100) { + _generateState.update { it.copy(error = "Укажите количество ключей (1–100)") } + return + } if (state.pid.isBlank()) { _generateState.update { it.copy(error = "Укажите product id") } return @@ -172,6 +196,7 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) pid = state.pid.trim(), maxDevices = maxDevices, validDays = validDays, + count = count, ) } ExpiryInputMode.FIXED -> { @@ -183,15 +208,27 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) pid = state.pid.trim(), maxDevices = maxDevices, expiresAtSec = expiresAtSec, + count = count, ) } } viewModelScope.launch { - _generateState.update { it.copy(isSubmitting = true, error = null, createdKey = null) } + _generateState.update { + it.copy(isSubmitting = true, error = null, createdKey = null, createdKeys = emptyList()) + } licenseRepository.createProductKey(currentSettings(), request) - .onSuccess { created -> - _generateState.update { it.copy(isSubmitting = false, createdKey = created) } + .onSuccess { result -> + when (result) { + is CreateProductKeyResult.Single -> + _generateState.update { + it.copy(isSubmitting = false, createdKey = result.key, createdKeys = emptyList()) + } + is CreateProductKeyResult.Bulk -> + _generateState.update { + it.copy(isSubmitting = false, createdKey = null, createdKeys = result.keys) + } + } loadLicenses() } .onFailure { e -> @@ -202,6 +239,46 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) } } + fun loadDownloadStats() { + viewModelScope.launch { + val currentSettings = currentSettings() + _statsState.update { it.copy(isLoading = true, error = null) } + licenseRepository.getDownloadStatsOverview(currentSettings) + .onSuccess { overview -> + val month = overview.currentMonth.month + _statsState.update { + it.copy( + isLoading = false, + months = overview.months, + selectedMonth = month, + stats = overview.currentMonth, + error = null, + ) + } + } + .onFailure { e -> + _statsState.update { + it.copy(isLoading = false, error = e.message ?: "Ошибка загрузки статистики") + } + } + } + } + + fun selectDownloadMonth(month: String) { + viewModelScope.launch { + _statsState.update { it.copy(selectedMonth = month, isLoading = true, error = null) } + licenseRepository.getDownloadStatsForMonth(currentSettings(), month) + .onSuccess { stats -> + _statsState.update { it.copy(isLoading = false, stats = stats, error = null) } + } + .onFailure { e -> + _statsState.update { + it.copy(isLoading = false, error = e.message ?: "Ошибка загрузки статистики") + } + } + } + } + fun updateAdminToken(value: String) { _settingsState.update { it.copy(adminToken = value, savedMessage = null) diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/GenerateKeyScreen.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/GenerateKeyScreen.kt index baafc66..c9c16c1 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/GenerateKeyScreen.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/GenerateKeyScreen.kt @@ -34,11 +34,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ru.mailib.ttrpg.licensemanager.R import ru.mailib.ttrpg.licensemanager.ui.ExpiryInputMode import ru.mailib.ttrpg.licensemanager.ui.LICENSE_TIER_PRESETS import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel @@ -78,12 +80,12 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) { verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Новый продуктовый ключ", + text = stringResource(R.string.generate_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, ) Text( - text = "Ключ будет добавлен в data.json на сервере. Передайте его пользователю для активации в TTRPG Player.", + text = stringResource(R.string.generate_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -91,7 +93,7 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) { OutlinedTextField( value = state.pid, onValueChange = viewModel::updatePid, - label = { Text("Product ID (pid)") }, + label = { Text(stringResource(R.string.product_id)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) @@ -99,13 +101,22 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) { OutlinedTextField( value = state.maxDevices, onValueChange = viewModel::updateMaxDevices, - label = { Text("Макс. устройств") }, + label = { Text(stringResource(R.string.max_devices)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) + OutlinedTextField( + value = state.count, + onValueChange = viewModel::updateCount, + label = { Text(stringResource(R.string.key_count)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + supportingText = { Text(stringResource(R.string.key_count_hint)) }, + ) + Text( - text = "Тип срока", + text = stringResource(R.string.expiry_type), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium, ) @@ -116,14 +127,14 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) { onClick = { viewModel.updateExpiryMode(ExpiryInputMode.PERIOD) }, shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), ) { - Text("Период (дней)") + Text(stringResource(R.string.expiry_period)) } SegmentedButton( selected = state.expiryMode == ExpiryInputMode.FIXED, onClick = { viewModel.updateExpiryMode(ExpiryInputMode.FIXED) }, shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), ) { - Text("Фикс. дата") + Text(stringResource(R.string.expiry_fixed)) } } @@ -132,15 +143,15 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) { OutlinedTextField( value = state.validDays, onValueChange = viewModel::updateValidDays, - label = { Text("Период (дней)") }, + label = { Text(stringResource(R.string.period_days)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, supportingText = { - Text("Отсчёт начинается при первой активации в TTRPG Player") + Text(stringResource(R.string.period_days_hint)) }, ) Text( - text = "Тарифы", + text = stringResource(R.string.tiers), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium, ) @@ -160,10 +171,10 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) { } ExpiryInputMode.FIXED -> { OutlinedButton(onClick = showDatePicker, modifier = Modifier.fillMaxWidth()) { - Text("Срок действия: ${state.expiryDate}") + Text(stringResource(R.string.expiry_date, state.expiryDate)) } Text( - text = "Дата фиксируется при создании ключа (старый формат)", + text = stringResource(R.string.expiry_fixed_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -185,32 +196,91 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) { strokeWidth = 2.dp, ) } else { - Text("Сгенерировать ключ") + Text(stringResource(R.string.generate_button)) } } state.createdKey?.let { created -> + Spacer(modifier = Modifier.height(8.dp)) + CreatedKeyCard( + key = created.key, + sub = created.sub, + expiry = formatCreatedKeyExpiry(created), + onCopy = { clipboard.setText(AnnotatedString(created.key)) }, + ) + } + + if (state.createdKeys.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), modifier = Modifier.fillMaxWidth(), ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Ключ создан", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text( - text = created.key, - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodyLarge, + text = stringResource(R.string.keys_created, state.createdKeys.size), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, ) - Text("sub: ${created.sub}") - Text("Срок: ${formatCreatedKeyExpiry(created)}") - IconButton( - onClick = { clipboard.setText(AnnotatedString(created.key)) }, - ) { - Icon(Icons.Default.ContentCopy, contentDescription = "Копировать ключ") + state.createdKeys.forEach { created -> + CreatedKeyRow( + key = created.key, + onCopy = { clipboard.setText(AnnotatedString(created.key)) }, + ) } } } } } } + +@Composable +private fun CreatedKeyCard( + key: String, + sub: String, + expiry: String, + onCopy: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.key_created), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = key, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyLarge, + ) + Text("sub: $sub") + Text(stringResource(R.string.expiry_label, expiry)) + IconButton(onClick = onCopy) { + Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_key)) + } + } + } +} + +@Composable +private fun CreatedKeyRow( + key: String, + onCopy: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = key, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton(onClick = onCopy) { + Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_key)) + } + } +} diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt index e508ab9..7784e66 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt @@ -31,19 +31,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ru.mailib.ttrpg.licensemanager.R import ru.mailib.ttrpg.licensemanager.data.LicenseEntry import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel +import ru.mailib.ttrpg.licensemanager.ui.formatEpochSec import ru.mailib.ttrpg.licensemanager.ui.formatLicenseExpiry @OptIn(ExperimentalMaterialApi::class) @Composable fun LicenseListScreen(viewModel: LicenseViewModel) { val state by viewModel.listState.collectAsStateWithLifecycle() - var confirmRevoke by remember { mutableStateOf(null) } + var confirmDelete by remember { mutableStateOf(null) } val refreshState = rememberPullRefreshState( refreshing = state.isLoading, onRefresh = viewModel::loadLicenses, @@ -53,26 +56,26 @@ fun LicenseListScreen(viewModel: LicenseViewModel) { viewModel.loadLicenses() } - confirmRevoke?.let { license -> + confirmDelete?.let { license -> AlertDialog( - onDismissRequest = { confirmRevoke = null }, - title = { Text("Отозвать лицензию?") }, + onDismissRequest = { confirmDelete = null }, + title = { Text(stringResource(R.string.delete_key_title)) }, text = { - Text("Ключ ${license.key} перестанет работать у всех пользователей.") + Text(stringResource(R.string.delete_key_message)) }, confirmButton = { TextButton( onClick = { - viewModel.revokeLicense(license.key) - confirmRevoke = null + viewModel.deleteLicense(license.key) + confirmDelete = null }, ) { - Text("Отозвать") + Text(stringResource(R.string.delete)) } }, dismissButton = { - TextButton(onClick = { confirmRevoke = null }) { - Text("Отмена") + TextButton(onClick = { confirmDelete = null }) { + Text(stringResource(R.string.cancel)) } }, ) @@ -97,7 +100,7 @@ fun LicenseListScreen(viewModel: LicenseViewModel) { state.licenses.isEmpty() -> { Text( - text = "Лицензий пока нет", + text = stringResource(R.string.no_licenses), modifier = Modifier.align(Alignment.Center), style = MaterialTheme.typography.bodyLarge, ) @@ -116,8 +119,8 @@ fun LicenseListScreen(viewModel: LicenseViewModel) { items(state.licenses, key = { it.key }) { license -> LicenseCard( license = license, - isRevoking = state.revokingKey == license.key, - onRevoke = { confirmRevoke = license }, + isDeleting = state.deletingKey == license.key, + onDelete = { confirmDelete = license }, ) } } @@ -135,8 +138,8 @@ fun LicenseListScreen(viewModel: LicenseViewModel) { @Composable private fun LicenseCard( license: LicenseEntry, - isRevoking: Boolean, - onRevoke: () -> Unit, + isDeleting: Boolean, + onDelete: () -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), @@ -157,23 +160,27 @@ private fun LicenseCard( ) DetailRow("sub", license.sub) DetailRow("pid", license.pid) - DetailRow("Устройства", "${license.activatedCount} / ${license.maxDevices}") - DetailRow("Срок", formatLicenseExpiry(license)) - DetailRow("Статус", if (license.revoked) "Отозвана" else "Активна") + license.createdAtSec?.let { sec -> + DetailRow(stringResource(R.string.created_at), formatEpochSec(sec)) + } + DetailRow(stringResource(R.string.devices), "${license.activatedCount} / ${license.maxDevices}") + DetailRow(stringResource(R.string.expiry), formatLicenseExpiry(license)) + DetailRow( + stringResource(R.string.status), + if (license.revoked) stringResource(R.string.status_revoked) else stringResource(R.string.status_active), + ) if (license.activatedDevices.isNotEmpty()) { DetailRow("deviceId", license.activatedDevices.joinToString(", ")) } - if (!license.revoked) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - OutlinedButton( - onClick = onRevoke, - enabled = !isRevoking, - ) { - if (isRevoking) { - CircularProgressIndicator(strokeWidth = 2.dp) - } else { - Text("Отозвать") - } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + OutlinedButton( + onClick = onDelete, + enabled = !isDeleting, + ) { + if (isDeleting) { + CircularProgressIndicator(strokeWidth = 2.dp) + } else { + Text(stringResource(R.string.delete)) } } } diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/StatsScreen.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/StatsScreen.kt new file mode 100644 index 0000000..4990925 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/StatsScreen.kt @@ -0,0 +1,219 @@ +package ru.mailib.ttrpg.licensemanager.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ru.mailib.ttrpg.licensemanager.R +import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel + +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@Composable +fun StatsScreen(viewModel: LicenseViewModel) { + val state by viewModel.statsState.collectAsStateWithLifecycle() + var monthMenuExpanded by remember { mutableStateOf(false) } + val refreshState = rememberPullRefreshState( + refreshing = state.isLoading, + onRefresh = viewModel::loadDownloadStats, + ) + + LaunchedEffect(Unit) { + viewModel.loadDownloadStats() + } + + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(refreshState), + ) { + when { + state.error != null && state.stats == null -> { + ErrorMessage( + message = state.error ?: "", + modifier = Modifier.align(Alignment.Center), + ) + } + + state.stats == null && state.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + else -> { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item(key = "header") { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.stats_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.stats_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (state.error != null) { + item(key = "error") { + ErrorMessage(message = state.error ?: "") + } + } + + item(key = "month-selector") { + val selectedMonth = state.selectedMonth + if (selectedMonth != null) { + ExposedDropdownMenuBox( + expanded = monthMenuExpanded, + onExpandedChange = { monthMenuExpanded = it }, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = selectedMonth, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.stats_month)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = monthMenuExpanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = monthMenuExpanded, + onDismissRequest = { monthMenuExpanded = false }, + ) { + val months = if (state.months.contains(selectedMonth)) { + state.months + } else { + listOf(selectedMonth) + state.months + } + months.forEach { month -> + DropdownMenuItem( + text = { Text(month) }, + onClick = { + monthMenuExpanded = false + if (month != selectedMonth) { + viewModel.selectDownloadMonth(month) + } + }, + ) + } + } + } + } + } + + state.stats?.let { stats -> + item(key = "total") { + StatCard( + label = stringResource(R.string.stats_total), + value = stats.total.toString(), + highlighted = true, + ) + } + items( + listOf( + "windows" to stats.windows, + "macos" to stats.macos, + "linux" to stats.linux, + ), + key = { it.first }, + ) { (platform, count) -> + StatCard( + label = platformLabel(platform), + value = count.toString(), + ) + } + } + } + } + } + + PullRefreshIndicator( + refreshing = state.isLoading, + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } +} + +@Composable +private fun platformLabel(platform: String): String = when (platform) { + "windows" -> stringResource(R.string.stats_windows) + "macos" -> stringResource(R.string.stats_macos) + "linux" -> stringResource(R.string.stats_linux) + else -> platform +} + +@Composable +private fun StatCard( + label: String, + value: String, + highlighted: Boolean = false, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (highlighted) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58bb1b1..4c0bd41 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,5 +3,46 @@ Менеджер лицензий TTRPG Лицензии Новый ключ + Статистика Настройки + + Удалить + Отмена + Удалить ключ? + Это действие необратимо. + Лицензий пока нет + Создан + Устройства + Срок + Статус + Активна + Отозвана + + Новый продуктовый ключ + Ключ будет добавлен в data.json на сервере. Передайте его пользователю для активации в TTRPG Player. + Product ID (pid) + Макс. устройств + Количество ключей + От 1 до 100 + Тип срока + Период (дней) + Фикс. дата + Период (дней) + Отсчёт начинается при первой активации в TTRPG Player + Тарифы + Срок действия: %1$s + Дата фиксируется при создании ключа (старый формат) + Сгенерировать ключ + Ключ создан + Создано ключей: %1$d + Срок: %1$s + Копировать ключ + + Скачивания + Количество скачиваний TTRPG Player по платформам за выбранный месяц. + Месяц + Всего + Windows + macOS + Linux