From c36f1d629a01cf810be74041cc38143cde05ce49 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Wed, 1 Jul 2026 22:46:11 +0800 Subject: [PATCH] Support period and fixed expiry modes in License Manager UI. Create keys with validDays by default and show activation-aware expiry in the license list. Co-authored-by: Cursor --- README.md | 2 + .../ttrpg/licensemanager/data/Models.kt | 13 ++- .../licensemanager/ui/LicenseViewModel.kt | 104 ++++++++++++++++-- .../ui/screens/GenerateKeyScreen.kt | 79 ++++++++++++- .../ui/screens/LicenseListScreen.kt | 4 +- 5 files changed, 181 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index aa13883..198134b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Android-приложение для администрирования лице - **Лицензии** — список всех продуктовых ключей с параметрами (`sub`, `pid`, лимит устройств, срок, статус отзыва, активированные `deviceId`). - **Новый ключ** — генерация продуктового ключа через `POST /v1/admin/product-keys`. + - По умолчанию — **period** (`validDays`, отсчёт с первой активации в TTRPG Player). + - Режим **фиксированная дата** — явный `expiresAtSec` (старый формат). - **Настройки** — URL сервера и admin token (`LICENSE_ADMIN_TOKEN`). ## Требования 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 392b6c1..1a098d0 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,7 +7,12 @@ data class LicenseEntry( val sub: String, val pid: String, val maxDevices: Int, - val expiresAtSec: Long, + val expiryMode: String? = null, + val expiresAtSec: Long? = null, + val validDays: Int? = null, + val activatedAtSec: Long? = null, + val licenseExpSec: Long? = null, + val effectiveExpSec: Long? = null, val revoked: Boolean, val activatedDevices: List, val activatedCount: Int, @@ -20,7 +25,8 @@ data class LicensesResponse( data class CreateProductKeyRequest( val pid: String = "dnd_player", val maxDevices: Int, - val expiresAtSec: Long, + val expiresAtSec: Long? = null, + val validDays: Int? = null, ) data class ProductKeyResponse( @@ -28,7 +34,8 @@ data class ProductKeyResponse( val sub: String, val pid: String, val maxDevices: Int, - val expiresAtSec: Long, + val expiresAtSec: Long? = null, + val validDays: Int? = null, ) data class RevokeLicenseRequest( 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 f40c551..0542a0f 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 @@ -7,8 +7,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch 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.LicenseEntry @@ -19,6 +19,23 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneOffset +enum class ExpiryInputMode { + PERIOD, + FIXED, +} + +data class LicenseTierPreset( + val label: String, + val validDays: Int, + val maxDevices: Int, +) + +val LICENSE_TIER_PRESETS = listOf( + LicenseTierPreset("Basic (1 год)", 365, 3), + LicenseTierPreset("Master (2 года)", 730, 5), + LicenseTierPreset("Legend (~99 лет)", 36_135, 15), +) + data class LicenseListUiState( val licenses: List = emptyList(), val isLoading: Boolean = false, @@ -29,6 +46,8 @@ data class LicenseListUiState( data class GenerateKeyUiState( val pid: String = "dnd_player", val maxDevices: String = "3", + 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, @@ -103,6 +122,25 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) _generateState.update { it.copy(maxDevices = value.filter { ch -> ch.isDigit() }.take(2), error = null) } } + fun updateExpiryMode(mode: ExpiryInputMode) { + _generateState.update { it.copy(expiryMode = mode, error = null) } + } + + fun updateValidDays(value: String) { + _generateState.update { it.copy(validDays = value.filter { ch -> ch.isDigit() }.take(5), error = null) } + } + + fun applyTierPreset(preset: LicenseTierPreset) { + _generateState.update { + it.copy( + expiryMode = ExpiryInputMode.PERIOD, + validDays = preset.validDays.toString(), + maxDevices = preset.maxDevices.toString(), + error = null, + ) + } + } + fun updateExpiryDate(date: LocalDate) { _generateState.update { it.copy(expiryDate = date, error = null) } } @@ -123,21 +161,35 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) return } - val expiresAtSec = state.expiryDate - .plusDays(1) - .atStartOfDay() - .toEpochSecond(ZoneOffset.UTC) - 1 - - viewModelScope.launch { - _generateState.update { it.copy(isSubmitting = true, error = null, createdKey = null) } - licenseRepository.createProductKey( - currentSettings(), + val request = when (state.expiryMode) { + ExpiryInputMode.PERIOD -> { + val validDays = state.validDays.toIntOrNull() + if (validDays == null || validDays < 1) { + _generateState.update { it.copy(error = "Укажите период в днях (минимум 1)") } + return + } + CreateProductKeyRequest( + pid = state.pid.trim(), + maxDevices = maxDevices, + validDays = validDays, + ) + } + ExpiryInputMode.FIXED -> { + val expiresAtSec = state.expiryDate + .plusDays(1) + .atStartOfDay() + .toEpochSecond(ZoneOffset.UTC) - 1 CreateProductKeyRequest( pid = state.pid.trim(), maxDevices = maxDevices, expiresAtSec = expiresAtSec, - ), - ) + ) + } + } + + viewModelScope.launch { + _generateState.update { it.copy(isSubmitting = true, error = null, createdKey = null) } + licenseRepository.createProductKey(currentSettings(), request) .onSuccess { created -> _generateState.update { it.copy(isSubmitting = false, createdKey = created) } loadLicenses() @@ -169,3 +221,31 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) fun formatEpochSec(sec: Long): String = Instant.ofEpochSecond(sec).atZone(ZoneOffset.UTC).toLocalDate().toString() + +fun formatLicenseExpiry(license: LicenseEntry): String { + val mode = license.expiryMode ?: if (license.validDays != null) "period" else "fixed" + return when (mode) { + "period" -> { + val days = license.validDays + if (days != null && license.licenseExpSec != null) { + "до ${formatEpochSec(license.licenseExpSec)} ($days дн.)" + } else if (days != null) { + "$days дн. · не активирован" + } else { + "—" + } + } + else -> { + val sec = license.effectiveExpSec ?: license.expiresAtSec + if (sec != null) formatEpochSec(sec) else "—" + } + } +} + +fun formatCreatedKeyExpiry(created: ProductKeyResponse): String { + return when { + created.validDays != null -> "${created.validDays} дн. с момента активации" + created.expiresAtSec != null -> "до ${formatEpochSec(created.expiresAtSec)}" + else -> "—" + } +} 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 398a257..baafc66 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 @@ -3,6 +3,8 @@ package ru.mailib.ttrpg.licensemanager.ui.screens import android.app.DatePickerDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -16,11 +18,15 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,11 +39,14 @@ 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.ui.ExpiryInputMode +import ru.mailib.ttrpg.licensemanager.ui.LICENSE_TIER_PRESETS import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel -import ru.mailib.ttrpg.licensemanager.ui.formatEpochSec +import ru.mailib.ttrpg.licensemanager.ui.formatCreatedKeyExpiry import java.time.LocalDate import java.util.Calendar +@OptIn(ExperimentalLayoutApi::class) @Composable fun GenerateKeyScreen(viewModel: LicenseViewModel) { val state by viewModel.generateState.collectAsStateWithLifecycle() @@ -95,8 +104,70 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) { singleLine = true, ) - OutlinedButton(onClick = showDatePicker, modifier = Modifier.fillMaxWidth()) { - Text("Срок действия: ${state.expiryDate}") + Text( + text = "Тип срока", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = state.expiryMode == ExpiryInputMode.PERIOD, + onClick = { viewModel.updateExpiryMode(ExpiryInputMode.PERIOD) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { + Text("Период (дней)") + } + SegmentedButton( + selected = state.expiryMode == ExpiryInputMode.FIXED, + onClick = { viewModel.updateExpiryMode(ExpiryInputMode.FIXED) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { + Text("Фикс. дата") + } + } + + when (state.expiryMode) { + ExpiryInputMode.PERIOD -> { + OutlinedTextField( + value = state.validDays, + onValueChange = viewModel::updateValidDays, + label = { Text("Период (дней)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + supportingText = { + Text("Отсчёт начинается при первой активации в TTRPG Player") + }, + ) + Text( + text = "Тарифы", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LICENSE_TIER_PRESETS.forEach { preset -> + FilterChip( + selected = state.validDays == preset.validDays.toString() && + state.maxDevices == preset.maxDevices.toString(), + onClick = { viewModel.applyTierPreset(preset) }, + label = { Text(preset.label) }, + ) + } + } + } + ExpiryInputMode.FIXED -> { + OutlinedButton(onClick = showDatePicker, modifier = Modifier.fillMaxWidth()) { + Text("Срок действия: ${state.expiryDate}") + } + Text( + text = "Дата фиксируется при создании ключа (старый формат)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } if (state.error != null) { @@ -132,7 +203,7 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) { style = MaterialTheme.typography.bodyLarge, ) Text("sub: ${created.sub}") - Text("Срок до: ${formatEpochSec(created.expiresAtSec)}") + Text("Срок: ${formatCreatedKeyExpiry(created)}") IconButton( onClick = { clipboard.setText(AnnotatedString(created.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 f273fd4..e508ab9 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 @@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -158,7 +158,7 @@ private fun LicenseCard( DetailRow("sub", license.sub) DetailRow("pid", license.pid) DetailRow("Устройства", "${license.activatedCount} / ${license.maxDevices}") - DetailRow("Срок до", formatEpochSec(license.expiresAtSec)) + DetailRow("Срок", formatLicenseExpiry(license)) DetailRow("Статус", if (license.revoked) "Отозвана" else "Активна") if (license.activatedDevices.isNotEmpty()) { DetailRow("deviceId", license.activatedDevices.joinToString(", "))