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:
@@ -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,
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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: ${created.sub}")
|
||||
Text("Срок: ${formatCreatedKeyExpiry(created)}")
|
||||
IconButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(created.key)) },
|
||||
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,
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Копировать ключ")
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user