Add delete, bulk key creation, download stats, and v1.1.0.

New stats tab, hard delete with confirmation, count field for batch keys, and license list sorted newest first.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-02 00:21:21 +08:00
parent c36f1d629a
commit 399f828ca8
10 changed files with 572 additions and 76 deletions
+2 -2
View File
@@ -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 {
@@ -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,
@@ -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
}
@@ -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<ProductKeyResponse>? = 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<ProductKeyResponse>) : 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<String>,
val currentMonth: DownloadStatsMonth,
)
data class ApiErrorBody(
@@ -55,12 +55,31 @@ class LicenseRepository(
suspend fun createProductKey(
settings: AppSettings,
request: CreateProductKeyRequest,
): Result<ProductKeyResponse> = runCatching {
createApi(settings).createProductKey(request)
): Result<CreateProductKeyResult> = 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<Unit> = runCatching {
createApi(settings).revokeLicense(RevokeLicenseRequest(key))
suspend fun deleteLicense(settings: AppSettings, key: String): Result<Unit> = runCatching {
createApi(settings).deleteProductKey(DeleteProductKeyRequest(key))
}
suspend fun getDownloadStatsOverview(settings: AppSettings): Result<DownloadStatsOverview> = runCatching {
createApi(settings).getDownloadStats()
}
suspend fun getDownloadStatsForMonth(
settings: AppSettings,
month: String,
): Result<DownloadStatsMonth> = runCatching {
createApi(settings).getDownloadStatsForMonth(month)
}
private fun createApi(settings: AppSettings): LicenseApi {
@@ -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<LicenseEntry> = 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<ProductKeyResponse> = emptyList(),
)
data class StatsUiState(
val months: List<String> = 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<GenerateKeyUiState> = _generateState.asStateFlow()
private val _statsState = MutableStateFlow(StatsUiState())
val statsState: StateFlow<StatsUiState> = _statsState.asStateFlow()
private val _settingsState = MutableStateFlow(SettingsUiState())
val settingsState: StateFlow<SettingsUiState> = _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)
@@ -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,
)
state.createdKeys.forEach { created ->
CreatedKeyRow(
key = created.key,
onCopy = { clipboard.setText(AnnotatedString(created.key)) },
)
Text("sub: ${created.sub}")
Text("Срок: ${formatCreatedKeyExpiry(created)}")
IconButton(
onClick = { clipboard.setText(AnnotatedString(created.key)) },
) {
Icon(Icons.Default.ContentCopy, contentDescription = "Копировать ключ")
}
}
}
}
}
}
@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))
}
}
}
@@ -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<LicenseEntry?>(null) }
var confirmDelete by remember { mutableStateOf<LicenseEntry?>(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,
onClick = onDelete,
enabled = !isDeleting,
) {
if (isRevoking) {
if (isDeleting) {
CircularProgressIndicator(strokeWidth = 2.dp)
} else {
Text("Отозвать")
}
Text(stringResource(R.string.delete))
}
}
}
@@ -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,
)
}
}
}
+41
View File
@@ -3,5 +3,46 @@
<string name="app_name">Менеджер лицензий TTRPG</string>
<string name="tab_licenses">Лицензии</string>
<string name="tab_generate">Новый ключ</string>
<string name="tab_stats">Статистика</string>
<string name="settings">Настройки</string>
<string name="delete">Удалить</string>
<string name="cancel">Отмена</string>
<string name="delete_key_title">Удалить ключ?</string>
<string name="delete_key_message">Это действие необратимо.</string>
<string name="no_licenses">Лицензий пока нет</string>
<string name="created_at">Создан</string>
<string name="devices">Устройства</string>
<string name="expiry">Срок</string>
<string name="status">Статус</string>
<string name="status_active">Активна</string>
<string name="status_revoked">Отозвана</string>
<string name="generate_title">Новый продуктовый ключ</string>
<string name="generate_description">Ключ будет добавлен в data.json на сервере. Передайте его пользователю для активации в TTRPG Player.</string>
<string name="product_id">Product ID (pid)</string>
<string name="max_devices">Макс. устройств</string>
<string name="key_count">Количество ключей</string>
<string name="key_count_hint">От 1 до 100</string>
<string name="expiry_type">Тип срока</string>
<string name="expiry_period">Период (дней)</string>
<string name="expiry_fixed">Фикс. дата</string>
<string name="period_days">Период (дней)</string>
<string name="period_days_hint">Отсчёт начинается при первой активации в TTRPG Player</string>
<string name="tiers">Тарифы</string>
<string name="expiry_date">Срок действия: %1$s</string>
<string name="expiry_fixed_hint">Дата фиксируется при создании ключа (старый формат)</string>
<string name="generate_button">Сгенерировать ключ</string>
<string name="key_created">Ключ создан</string>
<string name="keys_created">Создано ключей: %1$d</string>
<string name="expiry_label">Срок: %1$s</string>
<string name="copy_key">Копировать ключ</string>
<string name="stats_title">Скачивания</string>
<string name="stats_description">Количество скачиваний TTRPG Player по платформам за выбранный месяц.</string>
<string name="stats_month">Месяц</string>
<string name="stats_total">Всего</string>
<string name="stats_windows">Windows</string>
<string name="stats_macos">macOS</string>
<string name="stats_linux">Linux</string>
</resources>