CRONOS.
Мобильная платформа управления молодёжными мероприятиями. Включает систему кадрового резерва, AI-аналитику на базе Qwen 3.5, реалтайм-мониторинг безопасности и ролевую модель доступа.
Технологический стек
Проект построен на современном Android-стеке с декларативным UI и реактивным управлением состоянием через StateFlow.
Android / Kotlin
minSdk 24, targetSdk 34, Kotlin 1.9, JVM 17, AGP 8.3.2, Gradle 8.5
Jetpack Compose
Material 3, Navigation Compose 2.7.6, Hilt Navigation Compose 1.1.0, Compose BOM 2024.02.00
Hilt
Dagger Hilt 2.51, KSP 1.9.20-1.0.14, ViewModel injection через @HiltViewModel
Supabase
BOM 1.4.7 — postgrest-kt, gotrue-kt, realtime-kt. Таблица profiles в PostgreSQL
Ktor Client
Android engine 2.3.7, ContentNegotiation, kotlinx.serialization, OkHttp под капотом
Vico 1.13.1
papanicolas/vico compose-m3, столбчатые графики активности, entryModelOf() API
Coil 2.5.0
coil-compose + coil-gif для анимированных GIF-фонов на экране авторизации
CameraX + ZXing
androidx.camera 1.3.1, ZXing core 3.5.2 (isTransitive=false), QR-сканер участников
kotlinx.serialization
1.6.2, encodeDefaults=true, ignoreUnknownKeys=true глобально на Supabase-клиенте
Kotlin Coroutines
1.7.3, StateFlow для UI-состояния, viewModelScope для запросов, Flow для стриминга
Android PdfDocument
Встроенный android.graphics.pdf.PdfDocument, MediaStore API для Android 10+, папка Downloads
Gradle KTS
build.gradle.kts, --add-opens JVM args для совместимости AGP 8.3.2 + Gradle 8.5
// Supabase BOM
implementation(platform("io.github.jan-tennert.supabase:bom:1.4.7"))
implementation("io.github.jan-tennert.supabase:postgrest-kt")
implementation("io.github.jan-tennert.supabase:gotrue-kt")
implementation("io.github.jan-tennert.supabase:realtime-kt")
// Ktor HTTP Client
implementation("io.ktor:ktor-client-android:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
// Vico Charts
implementation("com.papanicolas.vico:compose-m3:1.13.1")
// Coil + GIF
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0")
// ZXing (без транзитивных зависимостей)
implementation("com.google.zxing:core:3.5.2") { isTransitive = false }
// Hilt
implementation("com.google.dagger:hilt-android:2.51")
ksp("com.google.dagger:hilt-compiler:2.51")
Архитектура
Паттерн MVVM + Clean Architecture. Однонаправленный поток данных через StateFlow. Dependency Injection через Hilt. Каждый экран имеет собственный ViewModel с изолированным UiState.
com.cronos.app/
├── data/
│ ├── model/ # Profile, PortfolioItem, EventApplication, AnticheatUser
│ └── repository/ # ProfileRepository, AiRepository, AppStateRepository, EventRepository
├── di/
│ └── AppModule.kt # Hilt-модуль: Supabase-клиент, Ktor HttpClient, репозитории
├── ui/
│ ├── components/ # CityDropdown, переиспользуемые Composable-компоненты
│ ├── navigation/ # CronosNavigation.kt — граф навигации NavHost
│ ├── screens/
│ │ ├── auth/ # LoginScreen, LoginViewModel
│ │ ├── onboarding/ # Participant/Organizer/ObserverOnboardingScreen
│ │ ├── dashboard/ # DashboardScreen (4 роли в одном файле)
│ │ ├── profile/ # ProfileScreen, ProfileViewModel
│ │ ├── events/ # EventsScreen, CreateEventScreen
│ │ ├── leaderboard/ # LeaderboardScreen
│ │ ├── messenger/ # MessengerScreen (ChatList + ChatDetail)
│ │ ├── stats/ # StatsScreen
│ │ ├── ai/ # AiHubScreen, AiHubViewModel
│ │ ├── inspector/ # InspectorScreen, InspectorViewModel
│ │ ├── organizer/ # OrganizerProfileScreen
│ │ ├── qr/ # QrScannerScreen
│ │ ├── rating/ # RatingScreen
│ │ └── admin/ # AdminProfileScreen, AdminMessengerScreen, AnticheatScreen+VM
│ └── theme/ # Color.kt, Theme.kt, Typography.kt
└── MainActivity.kt
@HiltViewModel
class InspectorViewModel @Inject constructor(
private val aiRepository: AiRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(InspectorUiState())
val uiState: StateFlow<InspectorUiState> = _uiState.asStateFlow()
fun scoreCandidate(participant: Participant) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result = aiRepository.scoreCandidate(participant)
_uiState.update { it.copy(isLoading = false, scoreResult = result) }
}
}
}
data class InspectorUiState(
val candidates: List<Participant> = emptyList(),
val isLoading: Boolean = false,
val scoreResult: String? = null,
val compareResult: String? = null,
val reserveRefreshSeconds: Long = 0L
)
@Provides @Singleton
fun provideSupabaseClient(): SupabaseClient = createSupabaseClient(
supabaseUrl = "https://<project>.supabase.co",
supabaseKey = "<anon-key>"
) {
install(GoTrue)
install(Postgrest)
install(Realtime)
defaultSerializer = KotlinXSerializer(Json {
ignoreUnknownKeys = true
encodeDefaults = true
})
}
Роли пользователей
Система поддерживает четыре роли. Роль определяется при регистрации, сохраняется в поле role таблицы profiles и определяет доступный дашборд и функциональность.
Участник
- Профиль с hero-блоком, вертикальным градиентом и белой рамкой аватара
- Уровни: Bronze / Silver / Gold / Reserve — цветовая маркировка бейджа
- Статистика: мероприятия, баллы, рейтинг, прогресс-бар до следующего уровня
- График активности по месяцам (Vico ColumnChart)
- Достижения с количеством очков за каждое
- Портфолио проектов с описанием и ссылками
- Лидерборд и персональный рейтинг
- Запись на мероприятия с фильтрами по направлению и формату
- AI-резюме, план роста до Reserve, подбор событий
- Мессенджер с организаторами
Организатор
- Публичный профиль с рейтингом доверия (прогресс-бар)
- Чипы типичных призов и наград
- Создание мероприятий: название, дата, формат, направление, лимит участников
- QR-сканер для регистрации участников на входе (CameraX + ZXing)
- Панель модерации заявок: одобрить / отклонить
- Настройки мероприятия и управление призовым фондом
- Дашборд с двумя вкладками: Профиль и Панель управления
Наблюдатель
- Инспектор кадрового резерва с поиском и фильтрами по городу и направлению
- Карточка кандидата: уровень, баллы, возраст, достижения, прогноз
- AI-скоринг кандидата: оценка 1–10, риски, рекомендация
- Сравнение двух кандидатов: 9-секционный отчёт 400+ слов
- Экспорт PDF-отчётов с AI-заключением в папку Downloads
- Таймер обновления резерва (12-часовой цикл, обратный отсчёт)
- Просмотр мероприятий и статистики платформы
Администратор
- Локальный вход без обращения к Supabase
- Отдельный Scaffold с 3-вкладочным нижним навбаром: Главная, Чат, Профиль
- Нет доступа к соревновательным функциям (Events, Leaderboard)
- Назначение модераторов по @username с валидацией дубликатов
- Чат исключительно с модераторами (значок верификации)
- Система AI-античита с реалтайм-мониторингом 500 пользователей
- Переключение AI-режима / ручной проверки в топбаре
Bronze → 0 – 199 баллов // начальный уровень
Silver → 200 – 499 баллов // активный участник
Gold → 500 – 999 баллов // опытный участник
Reserve → 1000+ баллов // кадровый резерв
Экраны приложения
Навигация реализована через Jetpack Navigation Compose. Граф маршрутов определён в CronosNavigation.kt. Каждый экран получает зависимости через Hilt.
| Экран | Маршрут | Роль | ViewModel | Описание |
|---|---|---|---|---|
| LoginScreen | login | Все | LoginViewModel | GIF-фон (Coil), тёмный оверлей, вход через Supabase или admin-bypass |
| RoleSelectionScreen | role_selection | Новые | — | Выбор роли при первом входе, 4 карточки с описанием |
| ParticipantOnboardingScreen | participant_onboarding | Участник | — | Имя, город (CityDropdown), дата рождения DD.MM.YYYY, направление |
| OrganizerOnboardingScreen | organizer_onboarding | Организатор | — | Название организации, направление деятельности, описание |
| ObserverOnboardingScreen | observer_onboarding | Наблюдатель | — | Должность, организация, цель наблюдения |
| DashboardScreen | dashboard | Все | DashboardViewModel | 4 независимых дашборда по роли, нижний навбар с иконками |
| ProfileScreen | profile | Участник | ProfileViewModel | Hero-блок с градиентом, статистика с иконками, Vico-график, достижения, портфолио |
| EventsScreen | events | Участник | — | Список мероприятий, фильтры по направлению и формату (онлайн/офлайн) |
| LeaderboardScreen | leaderboard | Участник | — | Рейтинг участников по баллам, топ-3 с подсветкой |
| MessengerScreen | messenger | Участник | — | Список чатов с организаторами, счётчики непрочитанных, кнопка назад |
| StatsScreen | stats | Участник | — | Детальная статистика: мероприятия по месяцам, направления, прогресс |
| AiHubScreen | ai_hub | Участник | AiHubViewModel | 3 AI-функции: резюме, подбор событий, план роста. Результат — только PDF |
| RatingScreen | rating | Участник | — | Личный рейтинг, прогресс до следующего уровня, история изменений |
| InspectorScreen | inspector | Наблюдатель | InspectorViewModel | Кандидаты резерва, AI-скоринг, сравнение двух кандидатов, PDF-экспорт, таймер |
| CreateEventScreen | create_event | Организатор | — | Форма создания мероприятия: название, дата, формат, лимит, призы |
| QrScannerScreen | qr_scanner | Организатор | — | CameraX Preview + ZXing BarcodeScanner, сканирование QR-кодов участников |
| OrganizerProfileScreen | organizer_profile | Организатор | — | Публичный профиль, рейтинг доверия, типичные призы, история мероприятий |
| AdminProfileScreen | admin_profile | Админ | — | Профиль администратора, форма назначения модераторов, список с кнопкой удаления |
| AdminMessengerScreen | admin_messenger | Админ | — | Чат только с модераторами, значок щита верификации, счётчики непрочитанных |
| AnticheatScreen | anticheat | Админ | AnticheatViewModel | Live-мониторинг пользователей, AI-анализ, ручная проверка, PDF-отчёт безопасности |
AI-модуль
Интеграция с Qwen 3.5 через GenAPI (OpenAI-совместимый прокси). Реализована в AiRepository.kt на базе Ktor HTTP Client. AI-текст никогда не отображается в UI — результат доступен только через PDF-экспорт.
endpoint : https://proxy.gen-api.ru/v1/chat/completions
model : qwen-3-5
max_tokens : 1500
temperature : 0.7
content-type : application/json
auth : Bearer <API_KEY>
AI-резюме достижений
Генерирует профессиональную самопрезентацию (3–4 предложения) на основе уровня, баллов, направления и достижений участника. Промпт включает все достижения с очками. Доступно в AiHubScreen. Результат — кнопка скачать PDF.
AI-подбор событий
Анализирует профиль участника (уровень, направление, город, интересы) и список доступных мероприятий. Возвращает топ-3 с обоснованием релевантности каждого события и рекомендацией по приоритету участия.
AI-план роста
Строит конкретный план достижения уровня Reserve: типы мероприятий, рекомендуемое количество в неделю, расчётные сроки в неделях, ключевые направления для прокачки. Учитывает текущий уровень и дефицит баллов.
AI-скоринг кандидата
В InspectorScreen — оценка потенциала по шкале 1–10, выявление рисков, рекомендация о включении в кадровый резерв. Промпт содержит полный профиль: уровень, баллы, возраст, город, направление, все достижения, прогноз. Результат — только PDF-отчёт.
AI-сравнение кандидатов
Подробный сравнительный отчёт из 9 разделов (400+ слов): общая активность, специализация, достижения, потенциал роста, риски, географический фактор, коммуникативные навыки, рекомендация по приоритету, итоговое заключение. Многостраничный PDF с автоматическим переносом страниц.
AI-сводка античита
Анализирует топ нарушителей из системы мониторинга: паттерны аномалий, статистику по платформе, рекомендации по улучшению системы безопасности. Результат — PDF-отчёт безопасности с заголовком, статистикой и заключением.
@Serializable
data class ChatRequest(
val model: String,
val messages: List<Message>,
val max_tokens: Int = 1500,
val temperature: Float = 0.7f
)
suspend fun scoreCandidate(participant: Participant): String {
val response = client.post(ENDPOINT) {
header("Authorization", "Bearer $API_KEY")
contentType(ContentType.Application.Json)
setBody(ChatRequest(model = MODEL, messages = listOf(
Message(role = "user", content = buildPrompt(participant))
)))
}
return extractContent(response.bodyAsText()).stripMarkdown()
}
// Удаление markdown из ответа AI
fun String.stripMarkdown() = this
.replace(Regex("\\*\\*(.+?)\\*\\*"), "$1")
.replace(Regex("#+\\s"), "")
.replace(Regex("\\*(.+?)\\*"), "$1")
.trim()
// 1. OpenAI standard string
choices[0].message.content → String
// 2. OpenAI content-array
choices[0].message.content → [{type: "text", text: "..."}]
// 3. Qwen thinking mode
choices[0].message.reasoning_content → String
// 4. GenAPI native format
output → String
response → String
Система античита
Реалтайм-мониторинг активности пользователей с AI-анализом аномалий. Реализована в AnticheatScreen + AnticheatViewModel. Поддерживает два режима: автоматический (AI) и ручной.
Автоматический бан
Аномальное начисление баллов (более 80 в день), множественные аккаунты с одного IP, рост уровня менее чем за 3 дня.
Предупреждение
Подозрительная активность: более 5 мероприятий за 24 часа. Требует ручной проверки модератором.
Чистый статус
Активность в пределах нормы. Модератор может вручную изменить статус через контекстное меню на карточке пользователя.
fun startStreaming() {
streamingJob = viewModelScope.launch {
var index = _uiState.value.streamIndex
while (isActive && _uiState.value.aiEnabled) {
if (index < allUsers.size) {
_uiState.update { state ->
state.copy(
visibleUsers = state.visibleUsers + allUsers[index],
streamIndex = index + 1,
isLive = true
)
}
index++
if (index % 10 == 0) delay(2000L) else delay(800L)
} else {
_uiState.update { it.copy(isLive = false) }
break
}
}
}
}
fun toggleAi(enabled: Boolean) {
if (!enabled) {
streamingJob?.cancel()
_uiState.update { it.copy(aiEnabled = false, isLive = false) }
} else {
_uiState.update { it.copy(aiEnabled = true) }
startStreaming()
}
}
Авторизация
Поддерживаются два режима входа: через Supabase Auth и локальный admin-bypass. После входа профиль загружается из таблицы profiles, поле role определяет дашборд.
Supabase Auth
Email + Password через gotrue-kt. После успешного входа загружается профиль из таблицы profiles. Поле role определяет дашборд. При первом входе — онбординг с выбором роли и заполнением профиля.
Admin Bypass
Локальная проверка без обращения к Supabase. Флаг AppStateRepository.isAdminMode активирует admin-дашборд и блокирует доступ к соревновательным функциям.
fun login(email: String, password: String) {
viewModelScope.launch {
// Локальный admin-bypass
if (email == "admin@gmail.com" && password == "admin123") {
appStateRepository.setAdminMode(true)
navigate("dashboard")
return@launch
}
// Supabase Auth
supabase.gotrue.signInWith(Email) {
this.email = email
this.password = password
}
val userId = supabase.gotrue.currentUserOrNull()?.id ?: return@launch
val profile = profileRepository.getProfile(userId)
if (profile == null) {
navigate("role_selection") // первый вход — онбординг
} else {
navigate("dashboard") // профиль есть — сразу дашборд
}
}
}
Модели данных
Основные data-классы приложения. Сериализация через kotlinx.serialization с аннотацией @Serializable.
@Serializable
data class Profile(
val id: String,
val displayName: String,
val username: String?,
val city: String?,
val role: String, // participant | organizer | observer | admin
val avatarUrl: String?,
val birthDate: String?, // YYYY-MM-DD
val direction: String?, // IT | Спорт | Культура | Наука | Бизнес
val interests: List<String>?,
val verificationStatus: String, // approved | pending | rejected
val rating: Int = 0,
val eventsCount: Int = 0,
val level: String = "Bronze" // Bronze | Silver | Gold | Reserve
)
data class Participant(
val id: String,
val name: String,
val username: String,
val city: String,
val direction: String,
val eventsCount: Int,
val rating: Int,
val level: String,
val age: Int,
val achievements: List<Pair<String, Int>>, // название → очки
val forecast: String // прогноз развития
)
data class AnticheatUser(
val id: String,
val name: String,
val username: String,
val pointsPerDay: Float, // аномалия если > 80
val eventsIn24h: Int, // аномалия если > 5
val duplicateIp: Boolean,
val rapidLevelUp: Boolean,
val rating: Int,
val status: BanStatus, // CLEAN | SUSPICIOUS | BANNED
val banReason: String?
)
enum class BanStatus { CLEAN, SUSPICIOUS, BANNED }
data class PortfolioItem(
val title: String,
val description: String,
val year: Int,
val url: String?,
val tags: List<String>
)
База данных
Backend — Supabase (PostgreSQL). Приложение работает с единственной таблицей profiles. Все остальные данные платформы хранятся и обрабатываются на стороне приложения.
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
display_name TEXT NOT NULL,
username TEXT UNIQUE,
city TEXT,
role TEXT NOT NULL CHECK (role IN ('participant','organizer','observer','admin')),
avatar_url TEXT,
birth_date DATE,
direction TEXT,
interests TEXT[],
verification_status TEXT DEFAULT 'pending' CHECK (verification_status IN ('approved','pending','rejected')),
rating INTEGER DEFAULT 0,
events_count INTEGER DEFAULT 0,
level TEXT DEFAULT 'Bronze',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
class ProfileRepository @Inject constructor(
private val supabase: SupabaseClient
) {
suspend fun getProfile(userId: String): Profile? =
supabase.postgrest["profiles"]
.select { filter { eq("id", userId) } }
.decodeSingleOrNull<Profile>()
suspend fun upsertProfile(profile: Profile) =
supabase.postgrest["profiles"].upsert(profile)
suspend fun updateRating(userId: String, delta: Int) =
supabase.postgrest["profiles"]
.update({ set("rating", "rating + $delta") }) {
filter { eq("id", userId) }
}
}
-- Пользователь видит только свой профиль
CREATE POLICY "profiles_select" ON profiles
FOR SELECT USING (auth.uid() = id);
-- Пользователь редактирует только свой профиль
CREATE POLICY "profiles_update" ON profiles
FOR UPDATE USING (auth.uid() = id);
PDF-экспорт
Все AI-отчёты экспортируются в PDF через встроенный android.graphics.pdf.PdfDocument. На Android 10+ используется MediaStore API для сохранения в папку Downloads без запроса разрешений.
Отчёт по кандидату
Имя, уровень, баллы, направление, достижения, AI-оценка 1–10, риски, рекомендация. Имя файла: Иван_Новиков.pdf
Сравнение кандидатов
Многостраничный PDF, 9 разделов, 400+ слов. Автоматический перенос страниц при превышении высоты. Имя файла: Сравнение_Иванов_Петров.pdf
AI-резюме / план роста
Профессиональная самопрезентация или план достижения уровня Reserve. Сохраняется в Downloads с именем участника.
Отчёт безопасности
Топ нарушителей, статистика по платформе (забанено/подозрительных/чистых), AI-заключение и рекомендации.
fun exportPdf(context: Context, content: String, fileName: String) {
val document = PdfDocument()
val pageInfo = PdfDocument.PageInfo.Builder(595, 842, 1).create()
var page = document.startPage(pageInfo)
val paint = Paint().apply { textSize = 12f; color = Color.BLACK }
var y = 40f
val lineHeight = 18f
val maxWidth = 515f
content.split("\n").forEach { line ->
val words = line.split(" ")
var currentLine = ""
words.forEach { word ->
val test = if (currentLine.isEmpty()) word else "$currentLine $word"
if (paint.measureText(test) > maxWidth) {
page.canvas.drawText(currentLine, 40f, y, paint)
y += lineHeight
if (y > 800f) { // новая страница
document.finishPage(page)
page = document.startPage(pageInfo)
y = 40f
}
currentLine = word
} else currentLine = test
}
if (currentLine.isNotEmpty()) {
page.canvas.drawText(currentLine, 40f, y, paint)
y += lineHeight
}
}
document.finishPage(page)
// MediaStore API (Android 10+)
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, "$fileName.pdf")
put(MediaStore.Downloads.MIME_TYPE, "application/pdf")
}
val uri = context.contentResolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI, values
)
uri?.let { context.contentResolver.openOutputStream(it)?.use { stream ->
document.writeTo(stream)
}}
document.close()
}
Безопасность
Меры безопасности на уровне приложения, базы данных и сетевого взаимодействия.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
UI-система
Тема приложения — чисто чёрный фон (pure black), Material 3. Все цвета определены в Color.kt, тема — в Theme.kt.
// Основные цвета
val Black = Color(0xFF000000)
val Surface = Color(0xFF0A0A0A)
val SurfaceVar = Color(0xFF111111)
val Primary = Color(0xFF00E5FF) // акцент — голубой
val OnPrimary = Color(0xFF000000)
// Уровни участников
val BronzeColor = Color(0xFFCD7F32)
val SilverColor = Color(0xFFC0C0C0)
val GoldColor = Color(0xFFFFD700)
val ReserveColor = Color(0xFF00E5FF)
Box(
modifier = Modifier
.fillMaxWidth()
.height(280.dp)
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
MaterialTheme.colorScheme.surface
)
)
)
) {
// Аватар с белой рамкой
Box(
modifier = Modifier
.size(88.dp)
.border(3.dp, Color.White, CircleShape)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
// Бейдж уровня с цветом
val levelColor = when (profile.level) {
"Gold" -> GoldColor
"Reserve" -> ReserveColor
"Silver" -> SilverColor
else -> BronzeColor
}
}
Выпадающий список городов
Поиск с фильтрацией по вводу, все города России, ExposedDropdownMenuBox из Material 3, используется в онбординге участника.
График активности
ColumnChart с данными по месяцам (Янв–Дек), entryModelOf() API, тема Material 3, отображается в ProfileScreen и StatsScreen.
Анимированный фон
LoginScreen использует register.gif из assets через Coil AsyncImage с ImageDecoder.Factory. Поверх — тёмный полупрозрачный оверлей.
Камера + ZXing
CameraX Preview в AndroidView, BarcodeScanner из ZXing, ImageAnalysis.Analyzer, результат передаётся через callback в ViewModel.