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:
@@ -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 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 -> "—"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) },
|
||||
) {
|
||||
|
||||
@@ -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(", "))
|
||||
|
||||
Reference in New Issue
Block a user