From bbb861f920b83bade2c1012c2a8fd9f2910780ff Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Sun, 28 Jun 2026 12:35:39 +0800 Subject: [PATCH] Initial Android app: license list and key generation. Kotlin Compose admin client for TTRPG Player license server with branding and settings for server URL and admin token. Co-authored-by: Cursor --- .gitignore | 16 ++ README.md | 53 ++++ app/build.gradle.kts | 67 +++++ app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 25 ++ .../ttrpg/licensemanager/MainActivity.kt | 125 +++++++++ .../ttrpg/licensemanager/data/LicenseApi.kt | 13 + .../ttrpg/licensemanager/data/Models.kt | 36 +++ .../ttrpg/licensemanager/data/Repositories.kt | 90 +++++++ .../mailib/ttrpg/licensemanager/ui/AppLogo.kt | 19 ++ .../licensemanager/ui/LicenseViewModel.kt | 158 +++++++++++ .../mailib/ttrpg/licensemanager/ui/Theme.kt | 35 +++ .../ui/screens/GenerateKeyScreen.kt | 145 ++++++++++ .../ui/screens/LicenseListScreen.kt | 146 ++++++++++ .../ui/screens/SettingsScreen.kt | 71 +++++ app/src/main/res/drawable/app-logo.svg | 10 + app/src/main/res/drawable/ic_app_logo.xml | 17 ++ .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 20 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/strings.xml | 7 + app/src/main/res/values/themes.xml | 4 + build.gradle.kts | 5 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle.kts | 18 ++ 30 files changed, 1459 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/MainActivity.kt create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/AppLogo.kt create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/Theme.kt create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/GenerateKeyScreen.kt create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt create mode 100644 app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/SettingsScreen.kt create mode 100644 app/src/main/res/drawable/app-logo.svg create mode 100644 app/src/main/res/drawable/ic_app_logo.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2694ad2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Gradle +.gradle/ +build/ +local.properties + +# Android Studio +.idea/ +*.iml +.DS_Store +/captures +.externalNativeBuild +.cxx + +# Keystore +*.jks +*.keystore diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed1b62e --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# TTRPG License Manager + +Android-приложение для администрирования лицензий **TTRPG Player** через сервер [DndGamePlayerLicenseServer](https://git.mailib.ru/ifontosh/DndGamePlayerLicenseServer). + +## Возможности + +- **Лицензии** — список всех продуктовых ключей с параметрами (`sub`, `pid`, лимит устройств, срок, статус отзыва, активированные `deviceId`). +- **Новый ключ** — генерация продуктового ключа через `POST /v1/admin/product-keys`. +- **Настройки** — URL сервера и admin token (`LICENSE_ADMIN_TOKEN`). + +## Требования + +- Android Studio Ladybug (2024.2+) или новее +- JDK 17 +- Android SDK 35 + +## Сборка + +1. Откройте проект в Android Studio. +2. При первом запуске укажите в **Настройках**: + - URL: `https://license.mailib.ru/` (или локальный сервер) + - Admin token с сервера (`LICENSE_ADMIN_TOKEN`) +3. Run на устройстве или эмуляторе. + +```bash +./gradlew assembleDebug +``` + +## API сервера + +Приложение использует admin-эндпоинты (Bearer token): + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/v1/admin/licenses` | Список лицензий | +| POST | `/v1/admin/product-keys` | Создание ключа | + +Перед использованием приложения задеплойте обновлённый сервер с этими эндпоинтами. + +## Логотип + +Иконка и брендинг — логотип TTRPG Player (фиолетовая молния `#8B5CF6`). + +## Локальная разработка с сервером + +```bash +cd DndGamePlayerLicenseServer +cp data.example.json data.json +# задайте LICENSE_PRIVATE_KEY_PEM +npm start +``` + +В приложении укажите URL `http://10.0.2.2:3847/` (эмулятор) и admin token. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..012b15a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "ru.mailib.ttrpg.licensemanager" + compileSdk = 35 + + defaultConfig { + applicationId = "ru.mailib.ttrpg.licensemanager" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.10.01") + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.navigation:navigation-compose:2.8.4") + implementation("androidx.datastore:datastore-preferences:1.1.1") + + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..b9ea008 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Keep Retrofit/Gson model fields +-keepclassmembers class ru.mailib.ttrpg.licensemanager.data.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..93dd69a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/MainActivity.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/MainActivity.kt new file mode 100644 index 0000000..4f9d3f8 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/MainActivity.kt @@ -0,0 +1,125 @@ +package ru.mailib.ttrpg.licensemanager + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +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.List +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import ru.mailib.ttrpg.licensemanager.ui.AppLogo +import ru.mailib.ttrpg.licensemanager.ui.LicenseManagerTheme +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 + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + LicenseManagerTheme { + LicenseManagerApp() + } + } + } +} + +private object Routes { + const val LIST = "list" + const val GENERATE = "generate" + const val SETTINGS = "settings" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LicenseManagerApp(viewModel: LicenseViewModel = viewModel()) { + val navController = rememberNavController() + val backStack by navController.currentBackStackEntryAsState() + val currentRoute = backStack?.destination?.route + + val topLevelRoutes = setOf(Routes.LIST, Routes.GENERATE) + val showBottomBar = currentRoute in topLevelRoutes + + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AppLogo(size = 32.dp) + Text(stringResource(R.string.app_name)) + } + }, + actions = { + IconButton(onClick = { navController.navigate(Routes.SETTINGS) }) { + Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings)) + } + }, + ) + }, + bottomBar = { + if (showBottomBar) { + NavigationBar { + NavigationBarItem( + selected = currentRoute == Routes.LIST, + onClick = { navController.navigate(Routes.LIST) }, + icon = { Icon(Icons.Default.List, contentDescription = null) }, + label = { Text(stringResource(R.string.tab_licenses)) }, + ) + NavigationBarItem( + selected = currentRoute == Routes.GENERATE, + onClick = { navController.navigate(Routes.GENERATE) }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + label = { Text(stringResource(R.string.tab_generate)) }, + ) + } + } + }, + ) { padding -> + NavHost( + navController = navController, + startDestination = Routes.LIST, + modifier = Modifier.padding(padding), + ) { + composable(Routes.LIST) { + LicenseListScreen(viewModel) + } + composable(Routes.GENERATE) { + GenerateKeyScreen(viewModel) + } + composable(Routes.SETTINGS) { + SettingsScreen( + viewModel = viewModel, + onBack = { navController.popBackStack() }, + ) + } + } + } +} diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt new file mode 100644 index 0000000..257157d --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt @@ -0,0 +1,13 @@ +package ru.mailib.ttrpg.licensemanager.data + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface LicenseApi { + @GET("v1/admin/licenses") + suspend fun listLicenses(): LicensesResponse + + @POST("v1/admin/product-keys") + suspend fun createProductKey(@Body body: CreateProductKeyRequest): ProductKeyResponse +} diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt new file mode 100644 index 0000000..94c8229 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt @@ -0,0 +1,36 @@ +package ru.mailib.ttrpg.licensemanager.data + +import com.google.gson.annotations.SerializedName + +data class LicenseEntry( + val key: String, + val sub: String, + val pid: String, + val maxDevices: Int, + val expiresAtSec: Long, + val revoked: Boolean, + val activatedDevices: List, + val activatedCount: Int, +) + +data class LicensesResponse( + val licenses: List, +) + +data class CreateProductKeyRequest( + val pid: String = "dnd_player", + val maxDevices: Int, + val expiresAtSec: Long, +) + +data class ProductKeyResponse( + val key: String, + val sub: String, + val pid: String, + val maxDevices: Int, + val expiresAtSec: Long, +) + +data class ApiErrorBody( + @SerializedName("error") val error: String?, +) diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt new file mode 100644 index 0000000..8807c74 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt @@ -0,0 +1,90 @@ +package ru.mailib.ttrpg.licensemanager.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +data class AppSettings( + val serverUrl: String = DEFAULT_SERVER_URL, + val adminToken: String = "", +) { + companion object { + const val DEFAULT_SERVER_URL = "https://license.mailib.ru/" + } +} + +class SettingsRepository(private val context: Context) { + private val serverUrlKey = stringPreferencesKey("server_url") + private val adminTokenKey = stringPreferencesKey("admin_token") + + val settings: Flow = context.dataStore.data.map { prefs -> + AppSettings( + serverUrl = prefs[serverUrlKey] ?: AppSettings.DEFAULT_SERVER_URL, + adminToken = prefs[adminTokenKey].orEmpty(), + ) + } + + suspend fun save(serverUrl: String, adminToken: String) { + context.dataStore.edit { prefs -> + prefs[serverUrlKey] = serverUrl.trim().let { url -> + if (url.endsWith("/")) url else "$url/" + } + prefs[adminTokenKey] = adminToken.trim() + } + } +} + +class LicenseRepository( + private val settingsRepository: SettingsRepository, +) { + suspend fun listLicenses(settings: AppSettings): Result> = runCatching { + createApi(settings).listLicenses().licenses + } + + suspend fun createProductKey( + settings: AppSettings, + request: CreateProductKeyRequest, + ): Result = runCatching { + createApi(settings).createProductKey(request) + } + + private fun createApi(settings: AppSettings): LicenseApi { + require(settings.adminToken.isNotBlank()) { "Укажите admin token в настройках" } + val baseUrl = settings.serverUrl + + val authInterceptor = Interceptor { chain -> + val request = chain.request().newBuilder() + .header("Authorization", "Bearer ${settings.adminToken}") + .build() + chain.proceed(request) + } + + val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + + val client = OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(logging) + .build() + + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(LicenseApi::class.java) + } +} diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/AppLogo.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/AppLogo.kt new file mode 100644 index 0000000..e5f5f39 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/AppLogo.kt @@ -0,0 +1,19 @@ +package ru.mailib.ttrpg.licensemanager.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import ru.mailib.ttrpg.licensemanager.R + +@Composable +fun AppLogo(modifier: Modifier = Modifier, size: Dp) { + Icon( + painter = painterResource(R.drawable.ic_app_logo), + contentDescription = null, + modifier = modifier.size(size), + tint = androidx.compose.ui.graphics.Color.Unspecified, + ) +} diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt new file mode 100644 index 0000000..3a5de32 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt @@ -0,0 +1,158 @@ +package ru.mailib.ttrpg.licensemanager.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +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 +import ru.mailib.ttrpg.licensemanager.data.LicenseRepository +import ru.mailib.ttrpg.licensemanager.data.ProductKeyResponse +import ru.mailib.ttrpg.licensemanager.data.SettingsRepository +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +data class LicenseListUiState( + val licenses: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +data class GenerateKeyUiState( + val pid: String = "dnd_player", + val maxDevices: String = "3", + val expiryDate: LocalDate = LocalDate.of(2027, 12, 31), + val isSubmitting: Boolean = false, + val error: String? = null, + val createdKey: ProductKeyResponse? = null, +) + +data class SettingsUiState( + val serverUrl: String = AppSettings.DEFAULT_SERVER_URL, + val adminToken: String = "", + val savedMessage: String? = null, +) + +class LicenseViewModel(application: Application) : AndroidViewModel(application) { + private val settingsRepository = SettingsRepository(application) + private val licenseRepository = LicenseRepository(settingsRepository) + + val settings: StateFlow = settingsRepository.settings + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()) + + private val _listState = MutableStateFlow(LicenseListUiState()) + val listState: StateFlow = _listState.asStateFlow() + + private val _generateState = MutableStateFlow(GenerateKeyUiState()) + val generateState: StateFlow = _generateState.asStateFlow() + + private val _settingsState = MutableStateFlow(SettingsUiState()) + val settingsState: StateFlow = _settingsState.asStateFlow() + + init { + viewModelScope.launch { + settings.collect { appSettings -> + _settingsState.update { + it.copy( + serverUrl = appSettings.serverUrl, + adminToken = appSettings.adminToken, + ) + } + } + } + } + + fun loadLicenses() { + viewModelScope.launch { + val currentSettings = settings.value + _listState.update { it.copy(isLoading = true, error = null) } + licenseRepository.listLicenses(currentSettings) + .onSuccess { licenses -> + _listState.update { it.copy(isLoading = false, licenses = licenses, error = null) } + } + .onFailure { e -> + _listState.update { it.copy(isLoading = false, error = e.message ?: "Ошибка загрузки") } + } + } + } + + fun updatePid(value: String) { + _generateState.update { it.copy(pid = value, error = null) } + } + + fun updateMaxDevices(value: String) { + _generateState.update { it.copy(maxDevices = value.filter { ch -> ch.isDigit() }.take(2), error = null) } + } + + fun updateExpiryDate(date: LocalDate) { + _generateState.update { it.copy(expiryDate = date, error = null) } + } + + fun clearCreatedKey() { + _generateState.update { it.copy(createdKey = null) } + } + + fun generateKey() { + val state = _generateState.value + val maxDevices = state.maxDevices.toIntOrNull() + if (maxDevices == null || maxDevices < 1) { + _generateState.update { it.copy(error = "Укажите число устройств (минимум 1)") } + return + } + if (state.pid.isBlank()) { + _generateState.update { it.copy(error = "Укажите product id") } + 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( + settings.value, + CreateProductKeyRequest( + pid = state.pid.trim(), + maxDevices = maxDevices, + expiresAtSec = expiresAtSec, + ), + ) + .onSuccess { created -> + _generateState.update { it.copy(isSubmitting = false, createdKey = created) } + loadLicenses() + } + .onFailure { e -> + _generateState.update { + it.copy(isSubmitting = false, error = e.message ?: "Не удалось создать ключ") + } + } + } + } + + fun updateSettingsDraft(serverUrl: String, adminToken: String) { + _settingsState.update { + it.copy(serverUrl = serverUrl, adminToken = adminToken, savedMessage = null) + } + } + + fun saveSettings() { + val draft = _settingsState.value + viewModelScope.launch { + settingsRepository.save(draft.serverUrl, draft.adminToken) + _settingsState.update { it.copy(savedMessage = "Сохранено") } + } + } +} + +fun formatEpochSec(sec: Long): String = + Instant.ofEpochSecond(sec).atZone(ZoneOffset.UTC).toLocalDate().toString() diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/Theme.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/Theme.kt new file mode 100644 index 0000000..19a9308 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/Theme.kt @@ -0,0 +1,35 @@ +package ru.mailib.ttrpg.licensemanager.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val BrandPurple = Color(0xFF8B5CF6) +private val BrandPurpleDark = Color(0xFF6D28D9) + +private val LightColors = lightColorScheme( + primary = BrandPurple, + onPrimary = Color.White, + primaryContainer = Color(0xFFEDE9FE), + onPrimaryContainer = BrandPurpleDark, + secondary = Color(0xFF64748B), +) + +private val DarkColors = darkColorScheme( + primary = BrandPurple, + onPrimary = Color.White, + primaryContainer = BrandPurpleDark, + onPrimaryContainer = Color(0xFFEDE9FE), + secondary = Color(0xFF94A3B8), +) + +@Composable +fun LicenseManagerTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors, + content = content, + ) +} diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/GenerateKeyScreen.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/GenerateKeyScreen.kt new file mode 100644 index 0000000..398a257 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/GenerateKeyScreen.kt @@ -0,0 +1,145 @@ +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.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.ui.LicenseViewModel +import ru.mailib.ttrpg.licensemanager.ui.formatEpochSec +import java.time.LocalDate +import java.util.Calendar + +@Composable +fun GenerateKeyScreen(viewModel: LicenseViewModel) { + val state by viewModel.generateState.collectAsStateWithLifecycle() + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + + val showDatePicker = remember(state.expiryDate) { + { + val cal = Calendar.getInstance().apply { + set(state.expiryDate.year, state.expiryDate.monthValue - 1, state.expiryDate.dayOfMonth) + } + DatePickerDialog( + context, + { _, year, month, day -> + viewModel.updateExpiryDate(LocalDate.of(year, month + 1, day)) + }, + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH), + cal.get(Calendar.DAY_OF_MONTH), + ).show() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Новый продуктовый ключ", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Ключ будет добавлен в data.json на сервере. Передайте его пользователю для активации в TTRPG Player.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = state.pid, + onValueChange = viewModel::updatePid, + label = { Text("Product ID (pid)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = state.maxDevices, + onValueChange = viewModel::updateMaxDevices, + label = { Text("Макс. устройств") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedButton(onClick = showDatePicker, modifier = Modifier.fillMaxWidth()) { + Text("Срок действия: ${state.expiryDate}") + } + + if (state.error != null) { + ErrorMessage(message = state.error ?: "") + } + + Button( + onClick = viewModel::generateKey, + enabled = !state.isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Сгенерировать ключ") + } + } + + state.createdKey?.let { created -> + 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, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyLarge, + ) + Text("sub: ${created.sub}") + Text("Срок до: ${formatEpochSec(created.expiresAtSec)}") + IconButton( + onClick = { clipboard.setText(AnnotatedString(created.key)) }, + ) { + Icon(Icons.Default.ContentCopy, contentDescription = "Копировать ключ") + } + } + } + } + } +} diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt new file mode 100644 index 0000000..a9a9871 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt @@ -0,0 +1,146 @@ +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.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.data.LicenseEntry +import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel +import ru.mailib.ttrpg.licensemanager.ui.formatEpochSec + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun LicenseListScreen(viewModel: LicenseViewModel) { + val state by viewModel.listState.collectAsStateWithLifecycle() + val refreshState = rememberPullRefreshState( + refreshing = state.isLoading, + onRefresh = viewModel::loadLicenses, + ) + + LaunchedEffect(Unit) { + viewModel.loadLicenses() + } + + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(refreshState), + ) { + when { + state.error != null && state.licenses.isEmpty() -> { + ErrorMessage( + message = state.error ?: "", + modifier = Modifier.align(Alignment.Center), + ) + } + + state.licenses.isEmpty() && state.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + state.licenses.isEmpty() -> { + Text( + text = "Лицензий пока нет", + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyLarge, + ) + } + + else -> { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.licenses, key = { it.sub }) { license -> + LicenseCard(license) + } + } + } + } + + PullRefreshIndicator( + refreshing = state.isLoading, + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } +} + +@Composable +private fun LicenseCard(license: LicenseEntry) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (license.revoked) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + ), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = license.key, + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + ) + DetailRow("sub", license.sub) + DetailRow("pid", license.pid) + DetailRow("Устройства", "${license.activatedCount} / ${license.maxDevices}") + DetailRow("Срок до", formatEpochSec(license.expiresAtSec)) + DetailRow("Статус", if (license.revoked) "Отозвана" else "Активна") + if (license.activatedDevices.isNotEmpty()) { + DetailRow("deviceId", license.activatedDevices.joinToString(", ")) + } + } + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Text( + text = "$label: $value", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +fun ErrorMessage(message: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.padding(24.dp), + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + ) { + Text( + text = message, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/SettingsScreen.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..e401692 --- /dev/null +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/SettingsScreen.kt @@ -0,0 +1,71 @@ +package ru.mailib.ttrpg.licensemanager.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ru.mailib.ttrpg.licensemanager.ui.LicenseViewModel + +@Composable +fun SettingsScreen(viewModel: LicenseViewModel, onBack: () -> Unit) { + val state by viewModel.settingsState.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Настройки сервера", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + + OutlinedTextField( + value = state.serverUrl, + onValueChange = { value -> viewModel.updateSettingsDraft(value, state.adminToken) }, + label = { Text("URL сервера") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = state.adminToken, + onValueChange = { value -> viewModel.updateSettingsDraft(state.serverUrl, value) }, + label = { Text("Admin token (LICENSE_ADMIN_TOKEN)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + + if (state.savedMessage != null) { + Text( + text = state.savedMessage ?: "", + color = MaterialTheme.colorScheme.primary, + ) + } + + Button( + onClick = { + viewModel.saveSettings() + onBack() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Сохранить") + } + } +} diff --git a/app/src/main/res/drawable/app-logo.svg b/app/src/main/res/drawable/app-logo.svg new file mode 100644 index 0000000..4889f7e --- /dev/null +++ b/app/src/main/res/drawable/app-logo.svg @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_app_logo.xml b/app/src/main/res/drawable/ic_app_logo.xml new file mode 100644 index 0000000..09f907d --- /dev/null +++ b/app/src/main/res/drawable/ic_app_logo.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..79587c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..5695fbf --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..da4afd5 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + TTRPG License Manager + Лицензии + Новый ключ + Настройки + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a940cb9 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +