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"
|
applicationId = "ru.mailib.ttrpg.licensemanager"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "1.0.2"
|
versionName = "1.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.List
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.GenerateKeyScreen
|
||||||
import ru.mailib.ttrpg.licensemanager.ui.screens.LicenseListScreen
|
import ru.mailib.ttrpg.licensemanager.ui.screens.LicenseListScreen
|
||||||
import ru.mailib.ttrpg.licensemanager.ui.screens.SettingsScreen
|
import ru.mailib.ttrpg.licensemanager.ui.screens.SettingsScreen
|
||||||
|
import ru.mailib.ttrpg.licensemanager.ui.screens.StatsScreen
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -52,6 +54,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
private object Routes {
|
private object Routes {
|
||||||
const val LIST = "list"
|
const val LIST = "list"
|
||||||
const val GENERATE = "generate"
|
const val GENERATE = "generate"
|
||||||
|
const val STATS = "stats"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ fun LicenseManagerApp(viewModel: LicenseViewModel = viewModel()) {
|
|||||||
val backStack by navController.currentBackStackEntryAsState()
|
val backStack by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStack?.destination?.route
|
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
|
val showBottomBar = currentRoute in topLevelRoutes
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -99,6 +102,12 @@ fun LicenseManagerApp(viewModel: LicenseViewModel = viewModel()) {
|
|||||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||||
label = { Text(stringResource(R.string.tab_generate)) },
|
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) {
|
composable(Routes.GENERATE) {
|
||||||
GenerateKeyScreen(viewModel)
|
GenerateKeyScreen(viewModel)
|
||||||
}
|
}
|
||||||
|
composable(Routes.STATS) {
|
||||||
|
StatsScreen(viewModel)
|
||||||
|
}
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
|
|||||||
@@ -3,14 +3,21 @@ package ru.mailib.ttrpg.licensemanager.data
|
|||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
interface LicenseApi {
|
interface LicenseApi {
|
||||||
@GET("v1/admin/licenses")
|
@GET("v1/admin/licenses")
|
||||||
suspend fun listLicenses(): LicensesResponse
|
suspend fun listLicenses(): LicensesResponse
|
||||||
|
|
||||||
@POST("v1/admin/product-keys")
|
@POST("v1/admin/product-keys")
|
||||||
suspend fun createProductKey(@Body body: CreateProductKeyRequest): ProductKeyResponse
|
suspend fun createProductKey(@Body body: CreateProductKeyRequest): CreateProductKeyApiResponse
|
||||||
|
|
||||||
@POST("v1/admin/revoke")
|
@POST("v1/admin/product-keys/delete")
|
||||||
suspend fun revokeLicense(@Body body: RevokeLicenseRequest): RevokeLicenseResponse
|
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 sub: String,
|
||||||
val pid: String,
|
val pid: String,
|
||||||
val maxDevices: Int,
|
val maxDevices: Int,
|
||||||
|
val createdAtSec: Long? = null,
|
||||||
val expiryMode: String? = null,
|
val expiryMode: String? = null,
|
||||||
val expiresAtSec: Long? = null,
|
val expiresAtSec: Long? = null,
|
||||||
val validDays: Int? = null,
|
val validDays: Int? = null,
|
||||||
@@ -27,6 +28,7 @@ data class CreateProductKeyRequest(
|
|||||||
val maxDevices: Int,
|
val maxDevices: Int,
|
||||||
val expiresAtSec: Long? = null,
|
val expiresAtSec: Long? = null,
|
||||||
val validDays: Int? = null,
|
val validDays: Int? = null,
|
||||||
|
val count: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ProductKeyResponse(
|
data class ProductKeyResponse(
|
||||||
@@ -38,12 +40,54 @@ data class ProductKeyResponse(
|
|||||||
val validDays: Int? = null,
|
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,
|
val key: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RevokeLicenseResponse(
|
data class DeleteProductKeyResponse(
|
||||||
val ok: Boolean,
|
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(
|
data class ApiErrorBody(
|
||||||
|
|||||||
@@ -55,12 +55,31 @@ class LicenseRepository(
|
|||||||
suspend fun createProductKey(
|
suspend fun createProductKey(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
request: CreateProductKeyRequest,
|
request: CreateProductKeyRequest,
|
||||||
): Result<ProductKeyResponse> = runCatching {
|
): Result<CreateProductKeyResult> = runCatching {
|
||||||
createApi(settings).createProductKey(request)
|
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 {
|
suspend fun deleteLicense(settings: AppSettings, key: String): Result<Unit> = runCatching {
|
||||||
createApi(settings).revokeLicense(RevokeLicenseRequest(key))
|
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 {
|
private fun createApi(settings: AppSettings): LicenseApi {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
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.CreateProductKeyResult
|
||||||
|
import ru.mailib.ttrpg.licensemanager.data.DownloadStatsMonth
|
||||||
import ru.mailib.ttrpg.licensemanager.data.LicenseEntry
|
import ru.mailib.ttrpg.licensemanager.data.LicenseEntry
|
||||||
import ru.mailib.ttrpg.licensemanager.data.LicenseRepository
|
import ru.mailib.ttrpg.licensemanager.data.LicenseRepository
|
||||||
import ru.mailib.ttrpg.licensemanager.data.ProductKeyResponse
|
import ru.mailib.ttrpg.licensemanager.data.ProductKeyResponse
|
||||||
@@ -39,19 +41,29 @@ val LICENSE_TIER_PRESETS = listOf(
|
|||||||
data class LicenseListUiState(
|
data class LicenseListUiState(
|
||||||
val licenses: List<LicenseEntry> = emptyList(),
|
val licenses: List<LicenseEntry> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val revokingKey: String? = null,
|
val deletingKey: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
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 count: String = "1",
|
||||||
val expiryMode: ExpiryInputMode = ExpiryInputMode.PERIOD,
|
val expiryMode: ExpiryInputMode = ExpiryInputMode.PERIOD,
|
||||||
val validDays: String = "365",
|
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,
|
||||||
val createdKey: ProductKeyResponse? = 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(
|
data class SettingsUiState(
|
||||||
@@ -71,6 +83,9 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private val _generateState = MutableStateFlow(GenerateKeyUiState())
|
private val _generateState = MutableStateFlow(GenerateKeyUiState())
|
||||||
val generateState: StateFlow<GenerateKeyUiState> = _generateState.asStateFlow()
|
val generateState: StateFlow<GenerateKeyUiState> = _generateState.asStateFlow()
|
||||||
|
|
||||||
|
private val _statsState = MutableStateFlow(StatsUiState())
|
||||||
|
val statsState: StateFlow<StatsUiState> = _statsState.asStateFlow()
|
||||||
|
|
||||||
private val _settingsState = MutableStateFlow(SettingsUiState())
|
private val _settingsState = MutableStateFlow(SettingsUiState())
|
||||||
val settingsState: StateFlow<SettingsUiState> = _settingsState.asStateFlow()
|
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 {
|
viewModelScope.launch {
|
||||||
_listState.update { it.copy(revokingKey = key, error = null) }
|
_listState.update { it.copy(deletingKey = key, error = null) }
|
||||||
licenseRepository.revokeLicense(currentSettings(), key)
|
licenseRepository.deleteLicense(currentSettings(), key)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
loadLicenses()
|
loadLicenses()
|
||||||
_listState.update { it.copy(revokingKey = null) }
|
_listState.update { it.copy(deletingKey = null) }
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
_listState.update {
|
_listState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
revokingKey = null,
|
deletingKey = null,
|
||||||
error = e.message ?: "Не удалось отозвать лицензию",
|
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) }
|
_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) {
|
fun updateExpiryMode(mode: ExpiryInputMode) {
|
||||||
_generateState.update { it.copy(expiryMode = mode, error = null) }
|
_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) }
|
_generateState.update { it.copy(expiryDate = date, error = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearCreatedKey() {
|
fun clearCreatedKeys() {
|
||||||
_generateState.update { it.copy(createdKey = null) }
|
_generateState.update { it.copy(createdKey = null, createdKeys = emptyList()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateKey() {
|
fun generateKey() {
|
||||||
@@ -156,6 +175,11 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
_generateState.update { it.copy(error = "Укажите число устройств (минимум 1)") }
|
_generateState.update { it.copy(error = "Укажите число устройств (минимум 1)") }
|
||||||
return
|
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()) {
|
if (state.pid.isBlank()) {
|
||||||
_generateState.update { it.copy(error = "Укажите product id") }
|
_generateState.update { it.copy(error = "Укажите product id") }
|
||||||
return
|
return
|
||||||
@@ -172,6 +196,7 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
pid = state.pid.trim(),
|
pid = state.pid.trim(),
|
||||||
maxDevices = maxDevices,
|
maxDevices = maxDevices,
|
||||||
validDays = validDays,
|
validDays = validDays,
|
||||||
|
count = count,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ExpiryInputMode.FIXED -> {
|
ExpiryInputMode.FIXED -> {
|
||||||
@@ -183,15 +208,27 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
pid = state.pid.trim(),
|
pid = state.pid.trim(),
|
||||||
maxDevices = maxDevices,
|
maxDevices = maxDevices,
|
||||||
expiresAtSec = expiresAtSec,
|
expiresAtSec = expiresAtSec,
|
||||||
|
count = count,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
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)
|
licenseRepository.createProductKey(currentSettings(), request)
|
||||||
.onSuccess { created ->
|
.onSuccess { result ->
|
||||||
_generateState.update { it.copy(isSubmitting = false, createdKey = created) }
|
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()
|
loadLicenses()
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.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) {
|
fun updateAdminToken(value: String) {
|
||||||
_settingsState.update {
|
_settingsState.update {
|
||||||
it.copy(adminToken = value, savedMessage = null)
|
it.copy(adminToken = value, savedMessage = null)
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.R
|
||||||
import ru.mailib.ttrpg.licensemanager.ui.ExpiryInputMode
|
import ru.mailib.ttrpg.licensemanager.ui.ExpiryInputMode
|
||||||
import ru.mailib.ttrpg.licensemanager.ui.LICENSE_TIER_PRESETS
|
import ru.mailib.ttrpg.licensemanager.ui.LICENSE_TIER_PRESETS
|
||||||
import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel
|
import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel
|
||||||
@@ -78,12 +80,12 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Новый продуктовый ключ",
|
text = stringResource(R.string.generate_title),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Ключ будет добавлен в data.json на сервере. Передайте его пользователю для активации в TTRPG Player.",
|
text = stringResource(R.string.generate_description),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -91,7 +93,7 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.pid,
|
value = state.pid,
|
||||||
onValueChange = viewModel::updatePid,
|
onValueChange = viewModel::updatePid,
|
||||||
label = { Text("Product ID (pid)") },
|
label = { Text(stringResource(R.string.product_id)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
@@ -99,13 +101,22 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.maxDevices,
|
value = state.maxDevices,
|
||||||
onValueChange = viewModel::updateMaxDevices,
|
onValueChange = viewModel::updateMaxDevices,
|
||||||
label = { Text("Макс. устройств") },
|
label = { Text(stringResource(R.string.max_devices)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
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 = "Тип срока",
|
text = stringResource(R.string.expiry_type),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
@@ -116,14 +127,14 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
|||||||
onClick = { viewModel.updateExpiryMode(ExpiryInputMode.PERIOD) },
|
onClick = { viewModel.updateExpiryMode(ExpiryInputMode.PERIOD) },
|
||||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
||||||
) {
|
) {
|
||||||
Text("Период (дней)")
|
Text(stringResource(R.string.expiry_period))
|
||||||
}
|
}
|
||||||
SegmentedButton(
|
SegmentedButton(
|
||||||
selected = state.expiryMode == ExpiryInputMode.FIXED,
|
selected = state.expiryMode == ExpiryInputMode.FIXED,
|
||||||
onClick = { viewModel.updateExpiryMode(ExpiryInputMode.FIXED) },
|
onClick = { viewModel.updateExpiryMode(ExpiryInputMode.FIXED) },
|
||||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||||
) {
|
) {
|
||||||
Text("Фикс. дата")
|
Text(stringResource(R.string.expiry_fixed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,15 +143,15 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.validDays,
|
value = state.validDays,
|
||||||
onValueChange = viewModel::updateValidDays,
|
onValueChange = viewModel::updateValidDays,
|
||||||
label = { Text("Период (дней)") },
|
label = { Text(stringResource(R.string.period_days)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
supportingText = {
|
supportingText = {
|
||||||
Text("Отсчёт начинается при первой активации в TTRPG Player")
|
Text(stringResource(R.string.period_days_hint))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Тарифы",
|
text = stringResource(R.string.tiers),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
@@ -160,10 +171,10 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
|||||||
}
|
}
|
||||||
ExpiryInputMode.FIXED -> {
|
ExpiryInputMode.FIXED -> {
|
||||||
OutlinedButton(onClick = showDatePicker, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = showDatePicker, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Срок действия: ${state.expiryDate}")
|
Text(stringResource(R.string.expiry_date, state.expiryDate))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Дата фиксируется при создании ключа (старый формат)",
|
text = stringResource(R.string.expiry_fixed_hint),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -185,32 +196,91 @@ fun GenerateKeyScreen(viewModel: LicenseViewModel) {
|
|||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Сгенерировать ключ")
|
Text(stringResource(R.string.generate_button))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.createdKey?.let { created ->
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Ключ создан", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
|
||||||
Text(
|
Text(
|
||||||
text = created.key,
|
text = stringResource(R.string.keys_created, state.createdKeys.size),
|
||||||
fontFamily = FontFamily.Monospace,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text("sub: ${created.sub}")
|
state.createdKeys.forEach { created ->
|
||||||
Text("Срок: ${formatCreatedKeyExpiry(created)}")
|
CreatedKeyRow(
|
||||||
IconButton(
|
key = created.key,
|
||||||
onClick = { clipboard.setText(AnnotatedString(created.key)) },
|
onCopy = { clipboard.setText(AnnotatedString(created.key)) },
|
||||||
) {
|
)
|
||||||
Icon(Icons.Default.ContentCopy, contentDescription = "Копировать ключ")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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: $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,
|
||||||
|
) {
|
||||||
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.R
|
||||||
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
|
import ru.mailib.ttrpg.licensemanager.ui.formatLicenseExpiry
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LicenseListScreen(viewModel: LicenseViewModel) {
|
fun LicenseListScreen(viewModel: LicenseViewModel) {
|
||||||
val state by viewModel.listState.collectAsStateWithLifecycle()
|
val state by viewModel.listState.collectAsStateWithLifecycle()
|
||||||
var confirmRevoke by remember { mutableStateOf<LicenseEntry?>(null) }
|
var confirmDelete by remember { mutableStateOf<LicenseEntry?>(null) }
|
||||||
val refreshState = rememberPullRefreshState(
|
val refreshState = rememberPullRefreshState(
|
||||||
refreshing = state.isLoading,
|
refreshing = state.isLoading,
|
||||||
onRefresh = viewModel::loadLicenses,
|
onRefresh = viewModel::loadLicenses,
|
||||||
@@ -53,26 +56,26 @@ fun LicenseListScreen(viewModel: LicenseViewModel) {
|
|||||||
viewModel.loadLicenses()
|
viewModel.loadLicenses()
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmRevoke?.let { license ->
|
confirmDelete?.let { license ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmRevoke = null },
|
onDismissRequest = { confirmDelete = null },
|
||||||
title = { Text("Отозвать лицензию?") },
|
title = { Text(stringResource(R.string.delete_key_title)) },
|
||||||
text = {
|
text = {
|
||||||
Text("Ключ ${license.key} перестанет работать у всех пользователей.")
|
Text(stringResource(R.string.delete_key_message))
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.revokeLicense(license.key)
|
viewModel.deleteLicense(license.key)
|
||||||
confirmRevoke = null
|
confirmDelete = null
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text("Отозвать")
|
Text(stringResource(R.string.delete))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { confirmRevoke = null }) {
|
TextButton(onClick = { confirmDelete = null }) {
|
||||||
Text("Отмена")
|
Text(stringResource(R.string.cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -97,7 +100,7 @@ fun LicenseListScreen(viewModel: LicenseViewModel) {
|
|||||||
|
|
||||||
state.licenses.isEmpty() -> {
|
state.licenses.isEmpty() -> {
|
||||||
Text(
|
Text(
|
||||||
text = "Лицензий пока нет",
|
text = stringResource(R.string.no_licenses),
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
)
|
)
|
||||||
@@ -116,8 +119,8 @@ fun LicenseListScreen(viewModel: LicenseViewModel) {
|
|||||||
items(state.licenses, key = { it.key }) { license ->
|
items(state.licenses, key = { it.key }) { license ->
|
||||||
LicenseCard(
|
LicenseCard(
|
||||||
license = license,
|
license = license,
|
||||||
isRevoking = state.revokingKey == license.key,
|
isDeleting = state.deletingKey == license.key,
|
||||||
onRevoke = { confirmRevoke = license },
|
onDelete = { confirmDelete = license },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,8 +138,8 @@ fun LicenseListScreen(viewModel: LicenseViewModel) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun LicenseCard(
|
private fun LicenseCard(
|
||||||
license: LicenseEntry,
|
license: LicenseEntry,
|
||||||
isRevoking: Boolean,
|
isDeleting: Boolean,
|
||||||
onRevoke: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -157,23 +160,27 @@ private fun LicenseCard(
|
|||||||
)
|
)
|
||||||
DetailRow("sub", license.sub)
|
DetailRow("sub", license.sub)
|
||||||
DetailRow("pid", license.pid)
|
DetailRow("pid", license.pid)
|
||||||
DetailRow("Устройства", "${license.activatedCount} / ${license.maxDevices}")
|
license.createdAtSec?.let { sec ->
|
||||||
DetailRow("Срок", formatLicenseExpiry(license))
|
DetailRow(stringResource(R.string.created_at), formatEpochSec(sec))
|
||||||
DetailRow("Статус", if (license.revoked) "Отозвана" else "Активна")
|
}
|
||||||
|
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()) {
|
if (license.activatedDevices.isNotEmpty()) {
|
||||||
DetailRow("deviceId", license.activatedDevices.joinToString(", "))
|
DetailRow("deviceId", license.activatedDevices.joinToString(", "))
|
||||||
}
|
}
|
||||||
if (!license.revoked) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
OutlinedButton(
|
||||||
OutlinedButton(
|
onClick = onDelete,
|
||||||
onClick = onRevoke,
|
enabled = !isDeleting,
|
||||||
enabled = !isRevoking,
|
) {
|
||||||
) {
|
if (isDeleting) {
|
||||||
if (isRevoking) {
|
CircularProgressIndicator(strokeWidth = 2.dp)
|
||||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
} else {
|
||||||
} else {
|
Text(stringResource(R.string.delete))
|
||||||
Text("Отозвать")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="app_name">Менеджер лицензий TTRPG</string>
|
||||||
<string name="tab_licenses">Лицензии</string>
|
<string name="tab_licenses">Лицензии</string>
|
||||||
<string name="tab_generate">Новый ключ</string>
|
<string name="tab_generate">Новый ключ</string>
|
||||||
|
<string name="tab_stats">Статистика</string>
|
||||||
<string name="settings">Настройки</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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user