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 <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-01 22:46:11 +08:00
parent 9799bca5e3
commit c36f1d629a
5 changed files with 181 additions and 21 deletions
+2
View File
@@ -6,6 +6,8 @@ Android-приложение для администрирования лице
- **Лицензии** — список всех продуктовых ключей с параметрами (`sub`, `pid`, лимит устройств, срок, статус отзыва, активированные `deviceId`).
- **Новый ключ** — генерация продуктового ключа через `POST /v1/admin/product-keys`.
- По умолчанию — **period** (`validDays`, отсчёт с первой активации в TTRPG Player).
- Режим **фиксированная дата** — явный `expiresAtSec` (старый формат).
- **Настройки** — URL сервера и admin token (`LICENSE_ADMIN_TOKEN`).
## Требования
@@ -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<String>,
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(
@@ -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<LicenseEntry> = 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 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
viewModelScope.launch {
_generateState.update { it.copy(isSubmitting = true, error = null, createdKey = null) }
licenseRepository.createProductKey(
currentSettings(),
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 -> ""
}
}
@@ -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,9 +104,71 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
singleLine = true,
)
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) {
ErrorMessage(message = state.error ?: "")
@@ -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)) },
) {
@@ -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(", "))