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:
@@ -6,6 +6,8 @@ Android-приложение для администрирования лице
|
|||||||
|
|
||||||
- **Лицензии** — список всех продуктовых ключей с параметрами (`sub`, `pid`, лимит устройств, срок, статус отзыва, активированные `deviceId`).
|
- **Лицензии** — список всех продуктовых ключей с параметрами (`sub`, `pid`, лимит устройств, срок, статус отзыва, активированные `deviceId`).
|
||||||
- **Новый ключ** — генерация продуктового ключа через `POST /v1/admin/product-keys`.
|
- **Новый ключ** — генерация продуктового ключа через `POST /v1/admin/product-keys`.
|
||||||
|
- По умолчанию — **period** (`validDays`, отсчёт с первой активации в TTRPG Player).
|
||||||
|
- Режим **фиксированная дата** — явный `expiresAtSec` (старый формат).
|
||||||
- **Настройки** — URL сервера и admin token (`LICENSE_ADMIN_TOKEN`).
|
- **Настройки** — URL сервера и admin token (`LICENSE_ADMIN_TOKEN`).
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ data class LicenseEntry(
|
|||||||
val sub: String,
|
val sub: String,
|
||||||
val pid: String,
|
val pid: String,
|
||||||
val maxDevices: Int,
|
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 revoked: Boolean,
|
||||||
val activatedDevices: List<String>,
|
val activatedDevices: List<String>,
|
||||||
val activatedCount: Int,
|
val activatedCount: Int,
|
||||||
@@ -20,7 +25,8 @@ data class LicensesResponse(
|
|||||||
data class CreateProductKeyRequest(
|
data class CreateProductKeyRequest(
|
||||||
val pid: String = "dnd_player",
|
val pid: String = "dnd_player",
|
||||||
val maxDevices: Int,
|
val maxDevices: Int,
|
||||||
val expiresAtSec: Long,
|
val expiresAtSec: Long? = null,
|
||||||
|
val validDays: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ProductKeyResponse(
|
data class ProductKeyResponse(
|
||||||
@@ -28,7 +34,8 @@ data class ProductKeyResponse(
|
|||||||
val sub: String,
|
val sub: String,
|
||||||
val pid: String,
|
val pid: String,
|
||||||
val maxDevices: Int,
|
val maxDevices: Int,
|
||||||
val expiresAtSec: Long,
|
val expiresAtSec: Long? = null,
|
||||||
|
val validDays: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RevokeLicenseRequest(
|
data class RevokeLicenseRequest(
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import ru.mailib.ttrpg.licensemanager.data.AppSettings
|
import ru.mailib.ttrpg.licensemanager.data.AppSettings
|
||||||
import ru.mailib.ttrpg.licensemanager.data.CreateProductKeyRequest
|
import ru.mailib.ttrpg.licensemanager.data.CreateProductKeyRequest
|
||||||
import ru.mailib.ttrpg.licensemanager.data.LicenseEntry
|
import ru.mailib.ttrpg.licensemanager.data.LicenseEntry
|
||||||
@@ -19,6 +19,23 @@ import java.time.Instant
|
|||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneOffset
|
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(
|
data class LicenseListUiState(
|
||||||
val licenses: List<LicenseEntry> = emptyList(),
|
val licenses: List<LicenseEntry> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -29,6 +46,8 @@ data class LicenseListUiState(
|
|||||||
data class GenerateKeyUiState(
|
data class GenerateKeyUiState(
|
||||||
val pid: String = "dnd_player",
|
val pid: String = "dnd_player",
|
||||||
val maxDevices: String = "3",
|
val maxDevices: String = "3",
|
||||||
|
val expiryMode: ExpiryInputMode = ExpiryInputMode.PERIOD,
|
||||||
|
val validDays: String = "365",
|
||||||
val expiryDate: LocalDate = LocalDate.of(2027, 12, 31),
|
val expiryDate: LocalDate = LocalDate.of(2027, 12, 31),
|
||||||
val isSubmitting: Boolean = false,
|
val isSubmitting: Boolean = false,
|
||||||
val error: String? = null,
|
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) }
|
_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) {
|
fun updateExpiryDate(date: LocalDate) {
|
||||||
_generateState.update { it.copy(expiryDate = date, error = null) }
|
_generateState.update { it.copy(expiryDate = date, error = null) }
|
||||||
}
|
}
|
||||||
@@ -123,21 +161,35 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val expiresAtSec = state.expiryDate
|
val request = when (state.expiryMode) {
|
||||||
.plusDays(1)
|
ExpiryInputMode.PERIOD -> {
|
||||||
.atStartOfDay()
|
val validDays = state.validDays.toIntOrNull()
|
||||||
.toEpochSecond(ZoneOffset.UTC) - 1
|
if (validDays == null || validDays < 1) {
|
||||||
|
_generateState.update { it.copy(error = "Укажите период в днях (минимум 1)") }
|
||||||
viewModelScope.launch {
|
return
|
||||||
_generateState.update { it.copy(isSubmitting = true, error = null, createdKey = null) }
|
}
|
||||||
licenseRepository.createProductKey(
|
CreateProductKeyRequest(
|
||||||
currentSettings(),
|
pid = state.pid.trim(),
|
||||||
|
maxDevices = maxDevices,
|
||||||
|
validDays = validDays,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ExpiryInputMode.FIXED -> {
|
||||||
|
val expiresAtSec = state.expiryDate
|
||||||
|
.plusDays(1)
|
||||||
|
.atStartOfDay()
|
||||||
|
.toEpochSecond(ZoneOffset.UTC) - 1
|
||||||
CreateProductKeyRequest(
|
CreateProductKeyRequest(
|
||||||
pid = state.pid.trim(),
|
pid = state.pid.trim(),
|
||||||
maxDevices = maxDevices,
|
maxDevices = maxDevices,
|
||||||
expiresAtSec = expiresAtSec,
|
expiresAtSec = expiresAtSec,
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_generateState.update { it.copy(isSubmitting = true, error = null, createdKey = null) }
|
||||||
|
licenseRepository.createProductKey(currentSettings(), request)
|
||||||
.onSuccess { created ->
|
.onSuccess { created ->
|
||||||
_generateState.update { it.copy(isSubmitting = false, createdKey = created) }
|
_generateState.update { it.copy(isSubmitting = false, createdKey = created) }
|
||||||
loadLicenses()
|
loadLicenses()
|
||||||
@@ -169,3 +221,31 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
fun formatEpochSec(sec: Long): String =
|
fun formatEpochSec(sec: Long): String =
|
||||||
Instant.ofEpochSecond(sec).atZone(ZoneOffset.UTC).toLocalDate().toString()
|
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 android.app.DatePickerDialog
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -16,11 +18,15 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
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.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.LicenseViewModel
|
||||||
import ru.mailib.ttrpg.licensemanager.ui.formatEpochSec
|
import ru.mailib.ttrpg.licensemanager.ui.formatCreatedKeyExpiry
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
||||||
val state by viewModel.generateState.collectAsStateWithLifecycle()
|
val state by viewModel.generateState.collectAsStateWithLifecycle()
|
||||||
@@ -95,8 +104,70 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedButton(onClick = showDatePicker, modifier = Modifier.fillMaxWidth()) {
|
Text(
|
||||||
Text("Срок действия: ${state.expiryDate}")
|
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) {
|
if (state.error != null) {
|
||||||
@@ -132,7 +203,7 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
|||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
)
|
)
|
||||||
Text("sub: ${created.sub}")
|
Text("sub: ${created.sub}")
|
||||||
Text("Срок до: ${formatEpochSec(created.expiresAtSec)}")
|
Text("Срок: ${formatCreatedKeyExpiry(created)}")
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { clipboard.setText(AnnotatedString(created.key)) },
|
onClick = { clipboard.setText(AnnotatedString(created.key)) },
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import ru.mailib.ttrpg.licensemanager.data.LicenseEntry
|
import ru.mailib.ttrpg.licensemanager.data.LicenseEntry
|
||||||
import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel
|
import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel
|
||||||
import ru.mailib.ttrpg.licensemanager.ui.formatEpochSec
|
import ru.mailib.ttrpg.licensemanager.ui.formatLicenseExpiry
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -158,7 +158,7 @@ private fun LicenseCard(
|
|||||||
DetailRow("sub", license.sub)
|
DetailRow("sub", license.sub)
|
||||||
DetailRow("pid", license.pid)
|
DetailRow("pid", license.pid)
|
||||||
DetailRow("Устройства", "${license.activatedCount} / ${license.maxDevices}")
|
DetailRow("Устройства", "${license.activatedCount} / ${license.maxDevices}")
|
||||||
DetailRow("Срок до", formatEpochSec(license.expiresAtSec))
|
DetailRow("Срок", formatLicenseExpiry(license))
|
||||||
DetailRow("Статус", if (license.revoked) "Отозвана" else "Активна")
|
DetailRow("Статус", if (license.revoked) "Отозвана" else "Активна")
|
||||||
if (license.activatedDevices.isNotEmpty()) {
|
if (license.activatedDevices.isNotEmpty()) {
|
||||||
DetailRow("deviceId", license.activatedDevices.joinToString(", "))
|
DetailRow("deviceId", license.activatedDevices.joinToString(", "))
|
||||||
|
|||||||
Reference in New Issue
Block a user