Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1077be1897 | ||
|
|
96db4fa3a6 | ||
|
|
830b4664fb | ||
|
|
dd4fb90dc4 | ||
|
|
a55fb0461c | ||
|
|
e789729c76 | ||
|
|
7e23895f0d | ||
|
|
708ad11dca | ||
|
|
b1418f79da | ||
|
|
d17c70e871 | ||
|
|
6cd211f744 | ||
|
|
019cf5da21 | ||
|
|
a24748ec04 | ||
|
|
caf3debb3f | ||
|
|
1c5140d751 | ||
|
|
65bb6cc7db | ||
|
|
99e05e8232 | ||
|
|
c4ecc43597 | ||
|
|
9a96bd3dac | ||
|
|
c4345f9dbe | ||
|
|
4797811097 | ||
|
|
2dfef426a7 | ||
|
|
97c19647cc | ||
|
|
dea27bc8f1 | ||
|
|
bafebfd253 | ||
|
|
f19d65abb5 | ||
|
|
889c57bbba | ||
|
|
ec6a7174e8 | ||
|
|
014896aaea | ||
|
|
9f6cf3ef30 | ||
|
|
d1923d3296 | ||
|
|
686976347e | ||
|
|
699bcd81f5 | ||
|
|
050f835a33 | ||
|
|
5fee57d93a | ||
|
|
36711d1bf3 | ||
|
|
c2a9cfb7e7 | ||
|
|
467c1a735f | ||
|
|
28965e6aed | ||
|
|
181f8b2f2a | ||
|
|
1fc43bb8fc | ||
|
|
32f93197ab | ||
|
|
32d0cec8ce | ||
|
|
72e63addb2 | ||
|
|
84ae97b906 | ||
|
|
2069ec811a | ||
|
|
ebe1c3c4a3 | ||
|
|
47ca253d6a | ||
|
|
766b37321d | ||
|
|
83a3ee68a8 | ||
|
|
0c8eb783a5 | ||
|
|
91ea47f63a | ||
|
|
8f4ab2783e | ||
|
|
72838d3448 | ||
|
|
fdb8d712e8 | ||
|
|
a8830bcd91 |
@@ -0,0 +1,125 @@
|
|||||||
|
# Требуемые секреты репозитория (Settings → Actions → Secrets):
|
||||||
|
# ANDROID_KEYSTORE — содержимое .jks файла (base64-кодированное)
|
||||||
|
# ANDROID_KEYSTORE_PASSWORD
|
||||||
|
# ANDROID_KEY_ALIAS
|
||||||
|
# ANDROID_KEY_PASSWORD
|
||||||
|
|
||||||
|
name: Release APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_notes:
|
||||||
|
description: 'Текст описания релиза (Markdown), можно оставить пустым'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
releases: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
# Ставим JDK вручную и явно прописываем JAVA_HOME.
|
||||||
|
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
cd /tmp
|
||||||
|
wget -q https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz
|
||||||
|
tar -xzf OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz -C /opt
|
||||||
|
mv /opt/jdk-17.0.12+7 /opt/java17
|
||||||
|
export JAVA_HOME=/opt/java17
|
||||||
|
export PATH=$JAVA_HOME/bin:$PATH
|
||||||
|
echo "JAVA_HOME=/opt/java17" >> $GITHUB_ENV
|
||||||
|
echo "$JAVA_HOME/bin" >> $GITHUB_PATH
|
||||||
|
java -version
|
||||||
|
javac -version
|
||||||
|
|
||||||
|
- name: Install Android SDK
|
||||||
|
# Устанавливаем command-line tools и нужные компоненты SDK,
|
||||||
|
# затем прописываем ANDROID_HOME (читается Gradle/AGP).
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
echo "ANDROID_HOME=/opt/android-sdk" >> "$GITHUB_ENV"
|
||||||
|
mkdir -p /opt/android-sdk/cmdline-tools
|
||||||
|
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/clt.zip
|
||||||
|
unzip -q /tmp/clt.zip -d /tmp/clt
|
||||||
|
mv /tmp/clt/cmdline-tools /opt/android-sdk/cmdline-tools/latest
|
||||||
|
yes | /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null 2>&1 || true
|
||||||
|
/opt/android-sdk/cmdline-tools/latest/bin/sdkmanager \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-36.1" \
|
||||||
|
"build-tools;36.1.0"
|
||||||
|
|
||||||
|
- name: Grant execute permission for gradlew
|
||||||
|
run: |
|
||||||
|
chmod +x gradlew
|
||||||
|
java -version
|
||||||
|
|
||||||
|
# Версия по дате (ГГГГ.ММ.ДД), при совпадении тега добавляем уникальный суффикс.
|
||||||
|
- name: Generate version from date
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
BASE="$(date +%Y.%m.%d)"
|
||||||
|
TAG="v$BASE"
|
||||||
|
SUFFIX=0
|
||||||
|
while git ls-remote --tags origin "$TAG" 2>/dev/null | grep -q "$TAG"; do
|
||||||
|
SUFFIX=$((SUFFIX + 1))
|
||||||
|
TAG="v${BASE}.${SUFFIX}"
|
||||||
|
done
|
||||||
|
echo "version_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version_tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Generated version: $BASE"
|
||||||
|
|
||||||
|
- name: Restore keystore from secret
|
||||||
|
# Ключ подписи передаётся как base64-секрет и декодируется в файл.
|
||||||
|
run: |
|
||||||
|
if [ -n "$ANDROID_KEYSTORE" ]; then
|
||||||
|
echo "$ANDROID_KEYSTORE" | base64 -d > keystore.jks
|
||||||
|
echo "keystore restored"
|
||||||
|
else
|
||||||
|
echo "::warning::ANDROID_KEYSTORE not set, building unsigned release"
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
run: ./gradlew assembleRelease
|
||||||
|
env:
|
||||||
|
# Версия приложения — дата, сгенерированная на предыдущем шаге.
|
||||||
|
# Читается в app/build.gradle.kts как versionName (для BuildConfig).
|
||||||
|
APP_VERSION: ${{ steps.version.outputs.version_name }}
|
||||||
|
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts
|
||||||
|
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
ORG_GRADLE_JVMARGS: '-Xmx2048m'
|
||||||
|
GRADLE_OPTS: '-Dorg.gradle.daemon=false'
|
||||||
|
|
||||||
|
- name: Determine APK path
|
||||||
|
id: apk
|
||||||
|
run: |
|
||||||
|
APK=$(find app/build/outputs/apk/release -name "*.apk" ! -name "*unsigned*" ! -name "*unaligned*" | head -n 1)
|
||||||
|
if [ -z "$APK" ]; then
|
||||||
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
|
||||||
|
fi
|
||||||
|
echo "apk_path=$APK" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "APK path: $APK"
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.version_tag }}
|
||||||
|
name: ${{ steps.version.outputs.version_name }}
|
||||||
|
body: ${{ github.event.inputs.release_notes }}
|
||||||
|
files: ${{ steps.apk.outputs.apk_path }}
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
server_url: ${{ gitea.server_url }}
|
||||||
@@ -17,3 +17,10 @@ local.properties
|
|||||||
|
|
||||||
/app/build
|
/app/build
|
||||||
/app/release
|
/app/release
|
||||||
|
/.idea
|
||||||
|
|
||||||
|
# Keystore подписи и его base64-представление для Gitea-секрета
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
/scripts/.keystore.b64
|
||||||
|
.keystore.b64
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.ksp)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "agzam.vyatsu"
|
||||||
|
compileSdk {
|
||||||
|
version = release(36) {
|
||||||
|
minorApiLevel = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "agzam.vyatsu"
|
||||||
|
minSdk = 28
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
|
||||||
|
// Локально и при отсутствии переменной — fallback "1.0".
|
||||||
|
versionName = System.getenv("APP_VERSION") ?: "debug"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Релизная подпись, ключ и пароли читаются из переменных окружения
|
||||||
|
// release-сборка не подписывается
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
val storePath = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||||
|
if (!storePath.isNullOrBlank()) {
|
||||||
|
storeFile = file(storePath)
|
||||||
|
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||||
|
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||||
|
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
// Подпись (если есть)
|
||||||
|
if (!System.getenv("ANDROID_KEYSTORE_PATH").isNullOrBlank()) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testOptions {
|
||||||
|
unitTests.isReturnDefaultValues = true
|
||||||
|
unitTests.all { it.maxHeapSize = "16m" }
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(11)
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksp {
|
||||||
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
// Room
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
implementation(libs.androidx.monitor)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
// Networking
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.okhttp.logging)
|
||||||
|
|
||||||
|
// HTML
|
||||||
|
implementation(libs.jsoup)
|
||||||
|
|
||||||
|
// JSON
|
||||||
|
implementation(libs.moshi)
|
||||||
|
implementation(libs.moshi.kotlin)
|
||||||
|
ksp(libs.moshi.kotlin.codegen)
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
implementation(libs.pdfbox.android)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation("org.apache.pdfbox:pdfbox:3.0.4")
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
# Игнорировать отсутствие класса декодера JP2 в PdfBox-Android
|
||||||
|
-dontwarn com.gemalto.jp2.JP2Decoder
|
||||||
|
# На всякий случай, если PdfBox ссылается на другие отсутствующие библиотеки (например, ICU для текста)
|
||||||
|
-dontwarn com.tom_roush.pdfbox.**
|
||||||
|
|
||||||
|
# Запретить R8 удалять и переименовывать внутренности PdfBox-Android
|
||||||
|
-keep class com.tom_roush.pdfbox.** { *; }
|
||||||
|
-keep interface com.tom_roush.pdfbox.** { *; }
|
||||||
|
|
||||||
|
# Moshi удаляется proguard
|
||||||
|
-keepnames @com.squareup.moshi.JsonClass class *
|
||||||
|
-keep class **JsonAdapter {
|
||||||
|
public <init>(...);
|
||||||
|
<fields>;
|
||||||
|
}
|
||||||
|
-keepclassmembers @com.squareup.moshi.JsonClass class * {
|
||||||
|
synthetic <init>(...);
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "48411ce0f1f22ef1dccd5852a9e97c5d",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "groups",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `autumnFirst` INTEGER, `autumnSecond` INTEGER, `springFirst` INTEGER, `springSecond` INTEGER, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "faculty",
|
||||||
|
"columnName": "faculty",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "autumnFirst",
|
||||||
|
"columnName": "autumnFirst",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "autumnSecond",
|
||||||
|
"columnName": "autumnSecond",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "springFirst",
|
||||||
|
"columnName": "springFirst",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "springSecond",
|
||||||
|
"columnName": "springSecond",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "schedules",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "groupId",
|
||||||
|
"columnName": "groupId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "season",
|
||||||
|
"columnName": "season",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "weekStart",
|
||||||
|
"columnName": "weekStart",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "weekEnd",
|
||||||
|
"columnName": "weekEnd",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_schedules_groupId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"groupId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_schedules_groupId_season_weekStart",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"groupId",
|
||||||
|
"season",
|
||||||
|
"weekStart"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "groups",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"groupId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "days",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "scheduleId",
|
||||||
|
"columnName": "scheduleId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"columnName": "date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_days_scheduleId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"scheduleId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_days_date",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "schedules",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"scheduleId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "lessons",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dayId",
|
||||||
|
"columnName": "dayId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"columnName": "date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "slotIndex",
|
||||||
|
"columnName": "slotIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "start",
|
||||||
|
"columnName": "start",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "end",
|
||||||
|
"columnName": "end",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lessonIndex",
|
||||||
|
"columnName": "lessonIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "raw",
|
||||||
|
"columnName": "raw",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "room",
|
||||||
|
"columnName": "room",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "groupName",
|
||||||
|
"columnName": "groupName",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subgroup",
|
||||||
|
"columnName": "subgroup",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subject",
|
||||||
|
"columnName": "subject",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "teacher",
|
||||||
|
"columnName": "teacher",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_lessons_dayId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"dayId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_lessons_date",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_lessons_subject",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"subject"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_lessons_room",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"room"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "days",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"dayId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "preferences",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "key",
|
||||||
|
"columnName": "key",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "value",
|
||||||
|
"columnName": "value",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '48411ce0f1f22ef1dccd5852a9e97c5d')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 3,
|
||||||
|
"identityHash": "3fd5eda9ac5e29fc96371ecf246e437c",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "groups",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "faculty",
|
||||||
|
"columnName": "faculty",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "group_periods",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "groupId",
|
||||||
|
"columnName": "groupId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "season",
|
||||||
|
"columnName": "season",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "weekStart",
|
||||||
|
"columnName": "weekStart",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "weekEnd",
|
||||||
|
"columnName": "weekEnd",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_group_periods_groupId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"groupId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_group_periods_groupId` ON `${TABLE_NAME}` (`groupId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "groups",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"groupId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "schedules",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "groupId",
|
||||||
|
"columnName": "groupId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "season",
|
||||||
|
"columnName": "season",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "weekStart",
|
||||||
|
"columnName": "weekStart",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "weekEnd",
|
||||||
|
"columnName": "weekEnd",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_schedules_groupId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"groupId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_schedules_groupId_season_weekStart",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"groupId",
|
||||||
|
"season",
|
||||||
|
"weekStart"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "groups",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"groupId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "days",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "scheduleId",
|
||||||
|
"columnName": "scheduleId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"columnName": "date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_days_scheduleId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"scheduleId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_days_date",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "schedules",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"scheduleId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "lessons",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dayId",
|
||||||
|
"columnName": "dayId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"columnName": "date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "slotIndex",
|
||||||
|
"columnName": "slotIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "start",
|
||||||
|
"columnName": "start",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "end",
|
||||||
|
"columnName": "end",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lessonIndex",
|
||||||
|
"columnName": "lessonIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "raw",
|
||||||
|
"columnName": "raw",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "room",
|
||||||
|
"columnName": "room",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "groupName",
|
||||||
|
"columnName": "groupName",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subgroup",
|
||||||
|
"columnName": "subgroup",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subject",
|
||||||
|
"columnName": "subject",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "teacher",
|
||||||
|
"columnName": "teacher",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_lessons_dayId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"dayId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_lessons_date",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_lessons_subject",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"subject"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_lessons_room",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"room"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "days",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"dayId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "preferences",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "key",
|
||||||
|
"columnName": "key",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "value",
|
||||||
|
"columnName": "value",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3fd5eda9ac5e29fc96371ecf246e437c')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package agzam.vyatsu
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("agzam.vyatsu", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".VyatsuApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@drawable/icon"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.VyatsuSchedule">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.VyatsuSchedule">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package agzam.vyatsu
|
||||||
|
|
||||||
|
import agzam.vyatsu.di.AppContainer
|
||||||
|
import agzam.vyatsu.ui.groups.GroupPickerScreen
|
||||||
|
import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
||||||
|
import agzam.vyatsu.ui.home.HomeScreen
|
||||||
|
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||||
|
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppRoot() {
|
||||||
|
val app = LocalContext.current.applicationContext as VyatsuApplication
|
||||||
|
val container = app.container
|
||||||
|
|
||||||
|
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
||||||
|
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
||||||
|
|
||||||
|
val state by scheduleViewModel.uiState.collectAsState()
|
||||||
|
val showFirstRunDialog by updateViewModel.showFirstRunDialog.collectAsState()
|
||||||
|
|
||||||
|
if (showFirstRunDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { updateViewModel.onFirstRunDecision(false) },
|
||||||
|
title = { Text("Автоматические обновления") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Разрешить автоматически проверять и устанавливать " +
|
||||||
|
"новые версии приложения?"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { updateViewModel.onFirstRunDecision(true) }) {
|
||||||
|
Text("Разрешить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { updateViewModel.onFirstRunDecision(false) }) {
|
||||||
|
Text("Не надо")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.noGroup) {
|
||||||
|
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||||
|
GroupPickerScreen(
|
||||||
|
viewModel = groupPickerViewModel,
|
||||||
|
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
HomeScreen(
|
||||||
|
scheduleViewModel = scheduleViewModel,
|
||||||
|
updateViewModel = updateViewModel,
|
||||||
|
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
|
||||||
|
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
||||||
|
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package agzam.vyatsu
|
||||||
|
|
||||||
|
import agzam.vyatsu.ui.theme.VyatsuScheduleTheme
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
val container = (application as VyatsuApplication).container
|
||||||
|
container.appUpdater.autoCheckAndInstall()
|
||||||
|
setContent {
|
||||||
|
VyatsuScheduleTheme {
|
||||||
|
AppRoot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package agzam.vyatsu
|
||||||
|
|
||||||
|
import agzam.vyatsu.di.AppContainer
|
||||||
|
import android.app.Application
|
||||||
|
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
|
||||||
|
|
||||||
|
class VyatsuApplication : Application() {
|
||||||
|
lateinit var container: AppContainer
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
PDFBoxResourceLoader.init(this)
|
||||||
|
container = AppContainer(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
GroupEntity::class,
|
||||||
|
GroupPeriodEntity::class,
|
||||||
|
ScheduleEntity::class,
|
||||||
|
DayEntity::class,
|
||||||
|
LessonEntity::class,
|
||||||
|
PreferenceEntity::class
|
||||||
|
],
|
||||||
|
version = 3,
|
||||||
|
exportSchema = true
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
abstract fun groupDao(): GroupDao
|
||||||
|
abstract fun scheduleDao(): ScheduleDao
|
||||||
|
abstract fun preferencesDao(): PreferencesDao
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
class Converters {
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun localDateToEpochDay(date: LocalDate?): Long? = date?.toEpochDay()
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun epochDayToLocalDate(epochDay: Long?): LocalDate? = epochDay?.let(LocalDate::ofEpochDay)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun localTimeToMinutes(time: LocalTime?): Int? = time?.let { it.hour * 60 + it.minute }
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun minutesToLocalTime(minutes: Int?): LocalTime? =
|
||||||
|
minutes?.let { LocalTime.of(it / 60, it % 60) }
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun lessonTypeToString(type: LessonType): String = type.name
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun stringToLessonType(value: String): LessonType =
|
||||||
|
runCatching { LessonType.valueOf(value) }.getOrDefault(LessonType.OTHER)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "days",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = ScheduleEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["scheduleId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index("scheduleId"),
|
||||||
|
Index("date")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class DayEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val scheduleId: Long,
|
||||||
|
val date: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface GroupDao {
|
||||||
|
@Query("SELECT * FROM groups ORDER BY name")
|
||||||
|
fun observeAll(): Flow<List<GroupEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM groups WHERE id = :id")
|
||||||
|
suspend fun getById(id: String): GroupEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM groups WHERE faculty = :faculty ORDER BY name")
|
||||||
|
suspend fun getByFaculty(faculty: String): List<GroupEntity>
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT faculty FROM groups ORDER BY faculty")
|
||||||
|
suspend fun getFaculties(): List<String>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAll(groups: List<GroupEntity>)
|
||||||
|
|
||||||
|
@Query("SELECT MAX(lastSyncedAt) FROM groups")
|
||||||
|
suspend fun maxSyncedAt(): Long?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM group_periods WHERE groupId = :groupId AND weekEnd >= :fromEpochDay ORDER BY weekStart")
|
||||||
|
suspend fun getCurrentAndFuturePeriods(groupId: String, fromEpochDay: Long): List<GroupPeriodEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM group_periods")
|
||||||
|
suspend fun deleteAllPeriods()
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertPeriods(periods: List<GroupPeriodEntity>)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "groups")
|
||||||
|
data class GroupEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val name: String,
|
||||||
|
val faculty: String,
|
||||||
|
val lastSyncedAt: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
// Все опубликованные на сайте 2-недельные периоды группы, включая ещё не начавшиеся.
|
||||||
|
@Entity(
|
||||||
|
tableName = "group_periods",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = GroupEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["groupId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [Index("groupId")]
|
||||||
|
)
|
||||||
|
data class GroupPeriodEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val groupId: String,
|
||||||
|
val season: Season,
|
||||||
|
val weekStart: Long,
|
||||||
|
val weekEnd: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "lessons",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = DayEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["dayId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index("dayId"),
|
||||||
|
Index("date"),
|
||||||
|
Index("subject"),
|
||||||
|
Index("room")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class LessonEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val dayId: Long,
|
||||||
|
val date: Long,
|
||||||
|
val slotIndex: Int,
|
||||||
|
val start: Int,
|
||||||
|
val end: Int,
|
||||||
|
val lessonIndex: Int,
|
||||||
|
val raw: String,
|
||||||
|
val room: String?,
|
||||||
|
val groupName: String?,
|
||||||
|
val subgroup: String?,
|
||||||
|
val subject: String?,
|
||||||
|
val type: LessonType,
|
||||||
|
val teacher: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "preferences")
|
||||||
|
data class PreferenceEntity(
|
||||||
|
@PrimaryKey val key: String,
|
||||||
|
val value: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface PreferencesDao {
|
||||||
|
@Query("SELECT value FROM preferences WHERE key = 'selectedGroupId'")
|
||||||
|
suspend fun selectedGroupId(): String?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedGroupId', :groupId)"
|
||||||
|
)
|
||||||
|
suspend fun setSelectedGroup(groupId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
|
||||||
|
suspend fun clearSelectedGroup()
|
||||||
|
|
||||||
|
@Query("SELECT value FROM preferences WHERE key = 'selectedWeekStart'")
|
||||||
|
suspend fun selectedWeekStart(): String?
|
||||||
|
|
||||||
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedWeekStart', :weekStart)")
|
||||||
|
suspend fun setSelectedWeekStart(weekStart: String)
|
||||||
|
|
||||||
|
@Query("SELECT value FROM preferences WHERE key = 'selectedDayDate'")
|
||||||
|
suspend fun selectedDayDate(): String?
|
||||||
|
|
||||||
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
|
||||||
|
suspend fun setSelectedDayDate(date: String)
|
||||||
|
|
||||||
|
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateEnabled'")
|
||||||
|
suspend fun autoUpdateEnabled(): String?
|
||||||
|
|
||||||
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateEnabled', :enabled)")
|
||||||
|
suspend fun setAutoUpdateEnabled(enabled: String)
|
||||||
|
|
||||||
|
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateAsked'")
|
||||||
|
suspend fun autoUpdateAsked(): String?
|
||||||
|
|
||||||
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateAsked', :asked)")
|
||||||
|
suspend fun setAutoUpdateAsked(asked: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ScheduleDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
|
||||||
|
fun observeSchedules(groupId: String): Flow<List<ScheduleEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
|
||||||
|
suspend fun getSchedules(groupId: String): List<ScheduleEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM days WHERE scheduleId = :scheduleId")
|
||||||
|
suspend fun deleteDaysForSchedule(scheduleId: Long)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
|
suspend fun insertSchedule(schedule: ScheduleEntity): Long
|
||||||
|
|
||||||
|
@Query("DELETE FROM schedules WHERE groupId = :groupId AND season = :season AND weekStart = :weekStart")
|
||||||
|
suspend fun deleteSchedule(groupId: String, season: String, weekStart: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM schedules WHERE groupId = :groupId")
|
||||||
|
suspend fun deleteAll(groupId: String)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
DELETE FROM schedules
|
||||||
|
WHERE groupId = :groupId
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id FROM schedules
|
||||||
|
WHERE groupId = :groupId
|
||||||
|
ORDER BY weekStart DESC
|
||||||
|
LIMIT :keep
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun trim(groupId: String, keep: Int)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertDays(days: List<DayEntity>): List<Long>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertLessons(lessons: List<LessonEntity>)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
|
||||||
|
"WHERE schedules.groupId = :groupId ORDER BY days.date"
|
||||||
|
)
|
||||||
|
suspend fun getDaysForGroup(groupId: String): List<DayEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
|
||||||
|
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
|
||||||
|
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
|
||||||
|
)
|
||||||
|
suspend fun getLessonsForGroup(groupId: String): List<LessonEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
|
||||||
|
"WHERE schedules.groupId = :groupId ORDER BY days.date"
|
||||||
|
)
|
||||||
|
fun observeDaysForGroup(groupId: String): Flow<List<DayEntity>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
|
||||||
|
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
|
||||||
|
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
|
||||||
|
)
|
||||||
|
fun observeLessonsForGroup(groupId: String): Flow<List<LessonEntity>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "schedules",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = GroupEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["groupId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index("groupId"),
|
||||||
|
Index(value = ["groupId", "season", "weekStart"], unique = true)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class ScheduleEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val groupId: String,
|
||||||
|
val season: Season,
|
||||||
|
val weekStart: Long,
|
||||||
|
val weekEnd: Long,
|
||||||
|
val lastSyncedAt: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package agzam.vyatsu.data.remote
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.DateRange
|
||||||
|
import agzam.vyatsu.domain.model.Group
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
object GroupsParser {
|
||||||
|
|
||||||
|
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
||||||
|
|
||||||
|
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
|
||||||
|
private val parenthesized = Regex("""\(.*?\)\s?""")
|
||||||
|
|
||||||
|
private const val CHUNK_SIZE = 8192
|
||||||
|
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
|
||||||
|
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
|
||||||
|
|
||||||
|
private val shorthands = mapOf(
|
||||||
|
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
||||||
|
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
||||||
|
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
|
||||||
|
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
|
||||||
|
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
|
||||||
|
"Факультет лингвистики (ОРУ)" to "ФЛ",
|
||||||
|
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
|
||||||
|
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
|
||||||
|
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
|
||||||
|
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
|
||||||
|
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
|
||||||
|
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
|
||||||
|
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
|
||||||
|
"Электротехнический факультет (ОРУ)" to "ЭТФ",
|
||||||
|
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
|
||||||
|
* разбирает его на лету, не материализуя весь HTML в память.
|
||||||
|
*
|
||||||
|
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
|
||||||
|
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
|
||||||
|
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
|
||||||
|
*/
|
||||||
|
fun parseStream(input: InputStream): ParsedGroups {
|
||||||
|
val reader = input.bufferedReader(Charsets.UTF_8)
|
||||||
|
val buf = CharArray(CHUNK_SIZE)
|
||||||
|
val pending = StringBuilder(CHUNK_SIZE * 2)
|
||||||
|
val groups = ArrayList<Group>()
|
||||||
|
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
||||||
|
var faculty = ""
|
||||||
|
|
||||||
|
fun addRanges(text: String) {
|
||||||
|
for (m in groupPattern.findAll(text)) {
|
||||||
|
val groupId = m.groupValues[1]
|
||||||
|
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
||||||
|
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
||||||
|
ranges.getOrPut(groupId) {
|
||||||
|
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
|
||||||
|
}.getOrPut(season) { ArrayList() }.add(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val n = reader.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
pending.append(buf, 0, n)
|
||||||
|
|
||||||
|
var pos = 0
|
||||||
|
while (pos < pending.length) {
|
||||||
|
val divAt = pending.indexOf("<div", pos)
|
||||||
|
val aAt = pending.indexOf("<a", pos)
|
||||||
|
val tagStart = when {
|
||||||
|
divAt < 0 -> aAt
|
||||||
|
aAt < 0 -> divAt
|
||||||
|
else -> minOf(divAt, aAt)
|
||||||
|
}
|
||||||
|
if (tagStart < 0) {
|
||||||
|
// хвостовой текст без div-тегов — обычный текст страницы
|
||||||
|
pos = pending.length
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (tagStart > pos) {
|
||||||
|
addRanges(pending.substring(pos, tagStart))
|
||||||
|
}
|
||||||
|
val tagEnd = findTagEnd(pending, tagStart)
|
||||||
|
if (tagEnd < 0) break // тег не дочитан — ждём следующий чанк
|
||||||
|
val tagText = pending.substring(tagStart, tagEnd + 1)
|
||||||
|
|
||||||
|
val isDiv = tagText.startsWith("<div")
|
||||||
|
val isFakName = isDiv && tagText.contains("class=\"fak_name\"")
|
||||||
|
val isGrpPeriod = isDiv && tagText.contains("class=\"grpPeriod\"")
|
||||||
|
if (isFakName || isGrpPeriod) {
|
||||||
|
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
|
||||||
|
if (closeDiv < 0) break // содержимое не дочитано — ждём следующий чанк
|
||||||
|
addRanges(tagText)
|
||||||
|
val content = pending.substring(tagEnd + 1, closeDiv)
|
||||||
|
if (isFakName) {
|
||||||
|
val name = content.trim()
|
||||||
|
if (name.isNotEmpty()) faculty = shortname(name)
|
||||||
|
} else {
|
||||||
|
val m = grpIdPattern.find(tagText)
|
||||||
|
if (m != null) {
|
||||||
|
groups += Group(
|
||||||
|
id = m.groupValues[1].dropLast(1),
|
||||||
|
name = removeParenthesized(content),
|
||||||
|
faculty = faculty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = closeDiv + 6
|
||||||
|
} else {
|
||||||
|
addRanges(tagText)
|
||||||
|
pos = tagEnd + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos > MAX_SLICE) {
|
||||||
|
pending.delete(0, pos)
|
||||||
|
pos = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos > 0 && pos <= pending.length) {
|
||||||
|
pending.delete(0, pos)
|
||||||
|
}
|
||||||
|
if (pending.length > MAX_PENDING) {
|
||||||
|
// на странице нет настолько длинных конструкций; страховка от аномалий
|
||||||
|
pending.delete(0, pending.length - MAX_SLICE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ParsedGroups(groups, ranges)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
|
||||||
|
private fun findTagEnd(s: CharSequence, start: Int): Int {
|
||||||
|
var i = start
|
||||||
|
var quote = 0.toChar()
|
||||||
|
while (i < s.length) {
|
||||||
|
val c = s[i]
|
||||||
|
if (quote != 0.toChar()) {
|
||||||
|
if (c == quote) quote = 0.toChar()
|
||||||
|
} else {
|
||||||
|
when (c) {
|
||||||
|
'"', '\'' -> quote = c
|
||||||
|
'>' -> return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortname(name: String): String {
|
||||||
|
val stripped = removeParenthesized(name)
|
||||||
|
val sh = shorthands[name]
|
||||||
|
return if (sh != null) "$sh ($stripped)" else stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ParsedGroups(
|
||||||
|
val groups: List<Group>,
|
||||||
|
val dateRanges: Map<String, Map<Season, List<DateRange>>>
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package agzam.vyatsu.data.remote
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
object VyatsuHttpClient {
|
||||||
|
|
||||||
|
private const val TAG = "HTTP"
|
||||||
|
|
||||||
|
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
suspend fun getText(url: String): String = withContext(Dispatchers.IO) {
|
||||||
|
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||||
|
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||||
|
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||||
|
resp.body!!.string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getBytes(url: String): ByteArray = withContext(Dispatchers.IO) {
|
||||||
|
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||||
|
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||||
|
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||||
|
resp.body!!.bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает потоковое тело ответа. Вызывающий обязан закрыть
|
||||||
|
* возвращённый [InputStream], чтобы вернуть соединение в пул.
|
||||||
|
*/
|
||||||
|
suspend fun openStream(url: String): InputStream = withContext(Dispatchers.IO) {
|
||||||
|
val resp = client.newCall(Request.Builder().url(url).build()).execute()
|
||||||
|
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||||
|
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||||
|
resp.body!!.byteStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package agzam.vyatsu.data.remote
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.DateRange
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
|
||||||
|
object VyatsuUrls {
|
||||||
|
|
||||||
|
const val GROUPS_PAGE = "https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/raspisanie-zanyatiy-dlya-studentov.html"
|
||||||
|
const val PDF_BASEURL = "https://www.vyatsu.ru/reports/schedule/Group"
|
||||||
|
|
||||||
|
fun groupPdfUrl(groupId: String, season: Season, range: DateRange): String = "$PDF_BASEURL/${groupId}_${season.apiKey}_${range.first}_${range.second}.pdf"
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package agzam.vyatsu.data.remote.pdf
|
||||||
|
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.PointF
|
||||||
|
import com.tom_roush.pdfbox.contentstream.PDFGraphicsStreamEngine
|
||||||
|
import com.tom_roush.pdfbox.cos.COSName
|
||||||
|
import com.tom_roush.pdfbox.pdmodel.PDPage
|
||||||
|
import com.tom_roush.pdfbox.pdmodel.graphics.color.PDColor
|
||||||
|
import com.tom_roush.pdfbox.pdmodel.graphics.image.PDImage
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - Парсит страницу PDF в набор линий
|
||||||
|
* - Позволяет получить таблицу
|
||||||
|
* - Позволяет конвертировать в SVG (для отладки)
|
||||||
|
*
|
||||||
|
* Требуется вызов [processPage] для парсинга
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
||||||
|
|
||||||
|
data class PdfLine(val x1: Float, val y1: Float, val x2: Float, val y2: Float) {
|
||||||
|
val left: Float get() = min(x1, x2)
|
||||||
|
val right: Float get() = max(x1, x2)
|
||||||
|
val top: Float get() = min(y1, y2)
|
||||||
|
val bottom: Float get() = max(y1, y2)
|
||||||
|
}
|
||||||
|
data class TableColumn(val x: Float, val width: Float, val cells: List<TableCell>) {
|
||||||
|
val right: Float get() = x + width
|
||||||
|
}
|
||||||
|
data class TableCell(val x: Float, val y: Float, val width: Float, val height: Float) {
|
||||||
|
val centerY: Float get() = y + height/2f
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _lines = mutableListOf<PdfLine>()
|
||||||
|
|
||||||
|
private val svgBuilder = StringBuilder()
|
||||||
|
private val currentPath = StringBuilder()
|
||||||
|
val tableSvg = StringBuilder()
|
||||||
|
|
||||||
|
|
||||||
|
private val currentSubPathLines = mutableListOf<PdfLine>()
|
||||||
|
private var lastPoint = PointF(0f, 0f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Представляет страницу в SVG формате
|
||||||
|
*/
|
||||||
|
fun asSvg(): String {
|
||||||
|
val mediaBox = page.mediaBox
|
||||||
|
val width = mediaBox.width
|
||||||
|
val height = mediaBox.height
|
||||||
|
return """
|
||||||
|
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style='font-size: 50%;'>
|
||||||
|
<g>${svgBuilder}</g>
|
||||||
|
<g>${tableSvg}</g>
|
||||||
|
</svg>
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun appendRectangle(rp0: PointF?, rp1: PointF?, rp2: PointF?, rp3: PointF?) {
|
||||||
|
if (rp0 == null || rp1 == null || rp2 == null || rp3 == null) return
|
||||||
|
val p0 = t(rp0)
|
||||||
|
val p1 = t(rp1)
|
||||||
|
val p2 = t(rp2)
|
||||||
|
val p3 = t(rp3)
|
||||||
|
currentPath.append(String.format(Locale.US, "M %.2f %.2f L %.2f %.2f L %.2f %.2f L %.2f %.2f Z ", p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y))
|
||||||
|
currentSubPathLines.add(PdfLine(p0.x, p0.y, p1.x, p1.y))
|
||||||
|
currentSubPathLines.add(PdfLine(p1.x, p1.y, p2.x, p2.y))
|
||||||
|
currentSubPathLines.add(PdfLine(p2.x, p2.y, p3.x, p3.y))
|
||||||
|
currentSubPathLines.add(PdfLine(p3.x, p3.y, p0.x, p0.y))
|
||||||
|
lastPoint.set(p3.x, p3.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun moveTo(rx: Float, ry: Float) {
|
||||||
|
val x = tx(rx)
|
||||||
|
val y = ty(ry)
|
||||||
|
currentPath.append(String.format(Locale.US, "M %.2f %.2f ", x, y))
|
||||||
|
lastPoint.set(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun lineTo(rx: Float, ry: Float) {
|
||||||
|
val x = tx(rx)
|
||||||
|
val y = ty(ry)
|
||||||
|
currentPath.append(String.format(Locale.US, "L %.2f %.2f ", x, y))
|
||||||
|
currentSubPathLines.add(PdfLine(lastPoint.x, lastPoint.y, x, y))
|
||||||
|
lastPoint.set(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun curveTo(rcx1: Float, rcy1: Float, rcx2: Float, rcy2: Float, ex: Float, ey: Float) {
|
||||||
|
val cx1 = tx(rcx1)
|
||||||
|
val cy1 = ty(rcy1)
|
||||||
|
val cx2 = tx(rcx2)
|
||||||
|
val cy2 = ty(rcy2)
|
||||||
|
val endX = tx(ex)
|
||||||
|
val endY = ty(ey)
|
||||||
|
currentPath.append(String.format(Locale.US, "C %.2f %.2f, %.2f %.2f, %.2f %.2f ", cx1, cy1, cx2, cy2, endX, endY))
|
||||||
|
lastPoint.set(endX, endY)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentPoint(): PointF {
|
||||||
|
return lastPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun closePath() {
|
||||||
|
currentPath.append("Z ")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun endPath() {
|
||||||
|
currentPath.clear()
|
||||||
|
currentSubPathLines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun strokePath() {
|
||||||
|
val strokeColor = graphicsState.strokingColor.toRGBHex()
|
||||||
|
val lineWidth = graphicsState.lineWidth
|
||||||
|
if (currentPath.isNotEmpty()) {
|
||||||
|
svgBuilder.append("<path d=\"$currentPath\" stroke=\"$strokeColor\" stroke-width=\"$lineWidth\" fill=\"none\" />\n")
|
||||||
|
_lines.addAll(currentSubPathLines)
|
||||||
|
}
|
||||||
|
currentPath.clear()
|
||||||
|
currentSubPathLines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fillPath(p0: Path.FillType?) {
|
||||||
|
val fillColor = graphicsState.nonStrokingColor.toRGBHex()
|
||||||
|
val rule = if (p0 == Path.FillType.EVEN_ODD) "evenodd" else "nonzero"
|
||||||
|
if (currentPath.isNotEmpty()) {
|
||||||
|
svgBuilder.append("<path d=\"$currentPath\" fill=\"$fillColor\" fill-rule=\"$rule\" />\n")
|
||||||
|
_lines.addAll(currentSubPathLines)
|
||||||
|
}
|
||||||
|
currentPath.clear()
|
||||||
|
currentSubPathLines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fillAndStrokePath(p0: Path.FillType?) {
|
||||||
|
val strokeColor = graphicsState.strokingColor.toRGBHex()
|
||||||
|
val fillColor = graphicsState.nonStrokingColor.toRGBHex()
|
||||||
|
val lineWidth = graphicsState.lineWidth
|
||||||
|
val rule = if (p0 == Path.FillType.EVEN_ODD) "evenodd" else "nonzero"
|
||||||
|
if (currentPath.isNotEmpty()) {
|
||||||
|
svgBuilder.append("<path d=\"$currentPath\" fill=\"$fillColor\" fill-rule=\"$rule\" stroke=\"$strokeColor\" stroke-width=\"$lineWidth\" />\n")
|
||||||
|
_lines.addAll(currentSubPathLines)
|
||||||
|
}
|
||||||
|
currentPath.clear()
|
||||||
|
currentSubPathLines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shadingFill(p0: COSName?) {
|
||||||
|
currentPath.clear()
|
||||||
|
currentSubPathLines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PDColor.toRGBHex(): String {
|
||||||
|
return try {
|
||||||
|
val rgb = this.toRGB()
|
||||||
|
String.format(Locale.US, "#%06X", rgb and 0xFFFFFF)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
"#000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun drawImage(p0: PDImage?) {}
|
||||||
|
override fun clip(p0: Path.FillType?) {}
|
||||||
|
|
||||||
|
private fun tx(x: Float): Float {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
private fun ty(y: Float): Float {
|
||||||
|
return page.mediaBox.height - y
|
||||||
|
}
|
||||||
|
private fun t(p: PointF): PointF {
|
||||||
|
return PointF(tx(p.x), ty(p.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Представляет страницу как набор колонок с ячейками
|
||||||
|
*
|
||||||
|
* Сначала делит страницу на столбики, затем, изолированно для каждого столбика делит его на ячейки
|
||||||
|
*/
|
||||||
|
fun asTable(): List<TableColumn> {
|
||||||
|
val verticalLines = _lines.filter { it.x1 == it.x2 }
|
||||||
|
val horizontalLines = _lines.filter { it.y1 == it.y2 }
|
||||||
|
|
||||||
|
// Список уникальных "x", чтобы разделить весь лист на колонки
|
||||||
|
val xs = merge(verticalLines.map { it.x1 })
|
||||||
|
|
||||||
|
for (v in horizontalLines) {
|
||||||
|
tableSvg.append("<rect fill='#0000ff55' x='${v.left}' y='${v.top}' width='${v.right-v.left}' height='3'/>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
val columns = mutableListOf<TableColumn>()
|
||||||
|
for (i in 0 until xs.size - 1) {
|
||||||
|
val left = xs[i]
|
||||||
|
val right = xs[i + 1]
|
||||||
|
|
||||||
|
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f00" else "#0f0"}' x='$left' y='3' width='${right-left}' height='3'/>\n")
|
||||||
|
|
||||||
|
val colWidth = right - left
|
||||||
|
if (colWidth <= 2f) continue
|
||||||
|
|
||||||
|
// Выборка горизонтальных линиий которые пересекают колонку
|
||||||
|
val linesInColumn = horizontalLines.filter { line -> (line.left-2f <= left && right <= line.right+2f) }
|
||||||
|
|
||||||
|
val ys = merge(linesInColumn.map { it.y1 }) // Уникальные "y", чтобы разделить уже столбик
|
||||||
|
|
||||||
|
val cells = mutableListOf<TableCell>()
|
||||||
|
for (ii in 0 until ys.size - 1) {
|
||||||
|
val top = ys[ii]
|
||||||
|
val bottom = ys[ii+1]
|
||||||
|
cells.add(TableCell(left, top, right-left, bottom-top))
|
||||||
|
|
||||||
|
tableSvg.append("<rect fill='${if (i % 2 == 0) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
|
||||||
|
}
|
||||||
|
columns.add(TableColumn(left, right-left, cells))
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убирает дубликаты, схлопывает близкие точки
|
||||||
|
*/
|
||||||
|
private fun merge(fs: List<Float>): List<Float> {
|
||||||
|
val s = fs.distinct().sorted()
|
||||||
|
val result = mutableListOf<Float>()
|
||||||
|
if(s.isEmpty()) return result
|
||||||
|
result.add(s[0])
|
||||||
|
var pad = 0f
|
||||||
|
for (i in 0 until s.size - 1) {
|
||||||
|
val dst = s[i + 1] - s[i]
|
||||||
|
if (dst <= 4f) {
|
||||||
|
pad = dst/2f
|
||||||
|
result[result.size-1] = result.last() + pad
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.add(s[i+1]+pad)
|
||||||
|
pad = 0f
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package agzam.vyatsu.data.remote.pdf
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Day
|
||||||
|
import agzam.vyatsu.domain.model.Lesson
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
|
import android.util.Log
|
||||||
|
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсинг расписания из PDF
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
object PdfScheduleParser {
|
||||||
|
|
||||||
|
private const val TAG = "PDF-SCHEDULE"
|
||||||
|
private const val SVG_TAG = "PDF-SVG"
|
||||||
|
|
||||||
|
private val TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
private val DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yy")
|
||||||
|
|
||||||
|
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
|
||||||
|
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
|
||||||
|
|
||||||
|
private val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
|
||||||
|
private val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" } )
|
||||||
|
private val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
fun parse(pdfBytes: ByteArray): List<Day> {
|
||||||
|
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
||||||
|
val doc = try {
|
||||||
|
PDDocument.load(pdfBytes)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "PDDocument.load failed", t)
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
|
||||||
|
val days = mutableListOf<Day>()
|
||||||
|
var group: String? = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (pageIndex in 0 until doc.numberOfPages) {
|
||||||
|
Log.v(TAG, "============ Page $pageIndex ============")
|
||||||
|
|
||||||
|
// Парсинг ячеек таблицы
|
||||||
|
val page = doc.getPage(pageIndex)
|
||||||
|
val svg = Pdf2Table(page)
|
||||||
|
svg.processPage(page)
|
||||||
|
val cols = svg.asTable()
|
||||||
|
|
||||||
|
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
|
||||||
|
|
||||||
|
// Парсинг текста
|
||||||
|
val textStripper = PdfTextParser()
|
||||||
|
textStripper.startPage = pageIndex + 1
|
||||||
|
textStripper.endPage = pageIndex + 1
|
||||||
|
textStripper.getText(doc)
|
||||||
|
val texts = textStripper.blocks
|
||||||
|
|
||||||
|
for(text in texts) {
|
||||||
|
svg.tableSvg.appendLine("<text x='${text.x}' y='${text.y}'>${text.text}</text>")
|
||||||
|
svg.tableSvg.appendLine("<circle cx='${text.x}' cy='${text.y}' r='1' fill='red'/>")
|
||||||
|
}
|
||||||
|
|
||||||
|
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
|
||||||
|
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
|
||||||
|
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
|
||||||
|
|
||||||
|
Log.v(TAG, "Days: ${cols[0].cells.size}")
|
||||||
|
Log.v(TAG, "Times: ${cols[1].cells.size}")
|
||||||
|
Log.v(TAG, "Types: ${cols[2].cells.size}")
|
||||||
|
|
||||||
|
/** Мерджит ячейки в одну строку, пробел в качестве слиятиля */
|
||||||
|
fun join(texts: List<PdfTextParser.TextBlock>): String {
|
||||||
|
val text = StringBuilder()
|
||||||
|
for (t in texts) {
|
||||||
|
if(!text.isEmpty()) text.append(' ')
|
||||||
|
text.append(t.text)
|
||||||
|
}
|
||||||
|
return text.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлекает контент из PDF внутри ячейки
|
||||||
|
* (Подразумевается что в ячейке нет "\n", поэтому используется пробел для слияния)
|
||||||
|
*/
|
||||||
|
fun content(cell: Pdf2Table.TableCell): String {
|
||||||
|
return join(texts.filter {
|
||||||
|
cell.x <= it.x && it.x <= cell.x + cell.width && cell.y <= it.y && it.y < cell.y + cell.height
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбирает все ячейки в целевой колонке, которые находятся на одном уровне (строке) с заданной ячейкой
|
||||||
|
* @param column - целевая колонка, в которой производится поиск ячеек.
|
||||||
|
* @param cell - ячейка-ориентир, определяющая границы строки по вертикали.
|
||||||
|
* @return Список ячеек из [column], попавших в диапазон строки
|
||||||
|
*/
|
||||||
|
fun select(column: Pdf2Table.TableColumn, cell: Pdf2Table.TableCell): List<Pdf2Table.TableCell> {
|
||||||
|
return column.cells.filter {
|
||||||
|
cell.y <= it.centerY && it.centerY < cell.y + cell.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (dayCell in daysColumn.cells) {
|
||||||
|
val day = content(dayCell).split(' ')
|
||||||
|
if(day.size == 1) {
|
||||||
|
Log.v(TAG, "day.first(): ${day.first()}")
|
||||||
|
}
|
||||||
|
if(day.size == 1 && day.first() == "День") {
|
||||||
|
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
|
||||||
|
group = content(groupCell)
|
||||||
|
Log.v(TAG, "Group: $group")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if(day.size != 2) {
|
||||||
|
Log.w(TAG, "Day data is not in format <day> <date>: $day")
|
||||||
|
continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
||||||
|
}
|
||||||
|
|
||||||
|
val date = dateFrom(day[1]) ?: continue
|
||||||
|
val intervals = mutableListOf<TimeSlot>()
|
||||||
|
// TODO: день недели (а нужен ли?)
|
||||||
|
|
||||||
|
Log.v(TAG, "############################## $day ############################## ")
|
||||||
|
for(intervalCell in select(intervalsColumn, dayCell)) { // День содержит интервалы
|
||||||
|
val timeContent = content(intervalCell)
|
||||||
|
val range = timeContent.split('-')
|
||||||
|
if(range.size != 2) continue // XXX: выбрасывать ошибку?
|
||||||
|
|
||||||
|
val start = timeFrom(range[0]) ?: continue
|
||||||
|
val end = timeFrom(range[1]) ?: continue
|
||||||
|
val lessons = mutableListOf<Lesson>()
|
||||||
|
|
||||||
|
Log.v(TAG, "=== $timeContent ===")
|
||||||
|
for(lessonCell in select(lessonsColumn, intervalCell)) { // Информация о паре, может быть разбита, например на подгруппы
|
||||||
|
val content = content(lessonCell)
|
||||||
|
Log.v(TAG, "- $content")
|
||||||
|
// TODO: парсинг контента лекции
|
||||||
|
|
||||||
|
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
|
||||||
|
var found = 0
|
||||||
|
var currentStart = 0
|
||||||
|
for (roomMatch in ROOM_REGEX.findAll(content)) {
|
||||||
|
found++
|
||||||
|
|
||||||
|
val raw = content.substring(currentStart, roomMatch.range.last + 1)
|
||||||
|
currentStart = roomMatch.range.last + 1
|
||||||
|
|
||||||
|
// Предполагаемый формат: [группа], [XX подгруппа] <предмет> <тип> [id + тип] <ФИО> <кабинет>
|
||||||
|
|
||||||
|
val room = roomMatch.value
|
||||||
|
|
||||||
|
val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList()
|
||||||
|
if(typeMatch.isEmpty()) {
|
||||||
|
lessons.add(Lesson(
|
||||||
|
raw=raw,
|
||||||
|
room=room,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var subject = raw.substring(0, typeMatch.first().range.first).trim() // TODO: сейчас [группа], [XX подгруппа] <предмет> - можно достать подгруппу
|
||||||
|
val type = LessonType.fromRaw(typeMatch.first().value.trim())
|
||||||
|
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
|
||||||
|
var subgroup: String? = null
|
||||||
|
Log.d(TAG, "$subject $group")
|
||||||
|
if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) {
|
||||||
|
subject = subject.substring(group.length + 1).trim()
|
||||||
|
val subgroupMatch = SUBGROUP_REGEX.find(subject)
|
||||||
|
if(subgroupMatch != null) {
|
||||||
|
subgroup = subgroupMatch.value.trim()
|
||||||
|
subject = subject.removeRange(subgroupMatch.range).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
lessons.add(Lesson(
|
||||||
|
raw=raw,
|
||||||
|
subject=subject,
|
||||||
|
room=room,
|
||||||
|
type=type,
|
||||||
|
teacher=teacher,
|
||||||
|
subgroup=subgroup
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if(found == 0) lessons.add(Lesson(raw=content))
|
||||||
|
|
||||||
|
}
|
||||||
|
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
|
||||||
|
}
|
||||||
|
days.add(Day(date=date, intervals=intervals))
|
||||||
|
}
|
||||||
|
for (text in texts.filter { it.rotation != 0f }) {
|
||||||
|
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
||||||
|
Log.v(SVG_TAG, "====")
|
||||||
|
Log.v(SVG_TAG+ "-" + (pageIndex+1), "====")
|
||||||
|
for(line in svg.asSvg().split("\n")) {
|
||||||
|
if(line.length > 250) {
|
||||||
|
Log.v(SVG_TAG, "length: " + line.length)
|
||||||
|
Log.v(SVG_TAG + "-" + (pageIndex+1), "length: " + line.length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Log.v(SVG_TAG, line)
|
||||||
|
Log.v(SVG_TAG + "-" + (pageIndex+1), line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Parsing error", e)
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
doc.close()
|
||||||
|
}
|
||||||
|
Log.v(TAG, "Days extracted: $days")
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package agzam.vyatsu.data.remote.pdf
|
||||||
|
|
||||||
|
import com.tom_roush.pdfbox.text.PDFTextStripper
|
||||||
|
import com.tom_roush.pdfbox.text.TextPosition
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсер блоков текста из PDF
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
|
||||||
|
class PdfTextParser : PDFTextStripper() {
|
||||||
|
|
||||||
|
data class TextBlock(
|
||||||
|
val text: String,
|
||||||
|
val x: Float,
|
||||||
|
val y: Float,
|
||||||
|
val width: Float,
|
||||||
|
val height: Float,
|
||||||
|
val rotation: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
val blocks = mutableListOf<TextBlock>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
sortByPosition = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
|
||||||
|
if (textPositions.isNullOrEmpty()) return
|
||||||
|
|
||||||
|
val currentWordChars = mutableListOf<TextPosition>()
|
||||||
|
|
||||||
|
for (tp in textPositions) {
|
||||||
|
val unicode = tp.unicode
|
||||||
|
|
||||||
|
if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
|
||||||
|
addWordBlock(currentWordChars)
|
||||||
|
currentWordChars.clear()
|
||||||
|
} else {
|
||||||
|
currentWordChars.add(tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addWordBlock(currentWordChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addWordBlock(wordChars: List<TextPosition>) {
|
||||||
|
if (wordChars.isEmpty()) return
|
||||||
|
|
||||||
|
val first = wordChars.first()
|
||||||
|
val last = wordChars.last()
|
||||||
|
|
||||||
|
val wordText = wordChars.joinToString("") { it.unicode }.trim()
|
||||||
|
if (wordText.isEmpty()) return
|
||||||
|
|
||||||
|
val matrix = first.textMatrix
|
||||||
|
val rotation = first.dir
|
||||||
|
|
||||||
|
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
|
||||||
|
|
||||||
|
val x = matrix.translateX
|
||||||
|
val y = matrix.translateY - first.heightDir
|
||||||
|
val height = first.heightDir
|
||||||
|
|
||||||
|
blocks.add(
|
||||||
|
TextBlock(
|
||||||
|
text = wordText,
|
||||||
|
x = x,
|
||||||
|
y = currentPage.mediaBox.height - y - height,
|
||||||
|
width = totalWidth,
|
||||||
|
height = height,
|
||||||
|
rotation = rotation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package agzam.vyatsu.data.remote.update
|
||||||
|
|
||||||
|
import com.squareup.moshi.Json
|
||||||
|
import com.squareup.moshi.JsonClass
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class GiteaRelease(
|
||||||
|
@Json(name = "tag_name") val tagName: String?,
|
||||||
|
val name: String?,
|
||||||
|
val body: String?,
|
||||||
|
val assets: List<GiteaAsset> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class GiteaAsset(
|
||||||
|
val name: String?,
|
||||||
|
@Json(name = "browser_download_url") val browserDownloadUrl: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
class GiteaReleaseApi(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
private val baseUrl: String = "https://vantablacktea.ddns.net",
|
||||||
|
private val repoPath: String = "agzam/vyatsu-schedule"
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val moshi = Moshi.Builder().build()
|
||||||
|
private val releaseAdapter = moshi.adapter(GiteaRelease::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает последний релиз или null, если релизов ещё нет (404).
|
||||||
|
*/
|
||||||
|
suspend fun latestRelease(): GiteaRelease? {
|
||||||
|
val url = "$baseUrl/api/v1/repos/$repoPath/releases/latest"
|
||||||
|
val request = Request.Builder().url(url).get().build()
|
||||||
|
|
||||||
|
return client.newCall(request).execute().use { resp ->
|
||||||
|
when {
|
||||||
|
resp.code == 404 -> null
|
||||||
|
!resp.isSuccessful -> throw IOException("HTTP ${resp.code} при запросе релиза")
|
||||||
|
else -> {
|
||||||
|
val body = resp.body ?: throw IOException("Пустой ответ сервера")
|
||||||
|
releaseAdapter.fromJson(body.source())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apkDownloadUrl(release: GiteaRelease): String? =
|
||||||
|
release.assets
|
||||||
|
.firstOrNull { it.name?.endsWith(".apk", ignoreCase = true) == true }
|
||||||
|
?.browserDownloadUrl
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package agzam.vyatsu.data.repository
|
||||||
|
|
||||||
|
import agzam.vyatsu.data.local.GroupDao
|
||||||
|
import agzam.vyatsu.data.local.GroupEntity
|
||||||
|
import agzam.vyatsu.data.local.GroupPeriodEntity
|
||||||
|
import agzam.vyatsu.data.remote.GroupsParser
|
||||||
|
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||||
|
import agzam.vyatsu.data.remote.VyatsuUrls
|
||||||
|
import agzam.vyatsu.domain.ScheduleDates
|
||||||
|
import agzam.vyatsu.domain.model.Group
|
||||||
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class GroupRepositoryImpl(
|
||||||
|
private val groupDao: GroupDao,
|
||||||
|
) : GroupRepository {
|
||||||
|
|
||||||
|
override fun observeGroups(): Flow<List<Group>> =
|
||||||
|
groupDao.observeAll().map { list -> list.map { it.toDomain() } }
|
||||||
|
|
||||||
|
override suspend fun getGroups(): List<Group> =
|
||||||
|
groupDao.observeAll().first().map { it.toDomain() }
|
||||||
|
|
||||||
|
override suspend fun getFaculties(): List<String> = groupDao.getFaculties()
|
||||||
|
|
||||||
|
override suspend fun refreshIfStale(): Boolean {
|
||||||
|
val last = groupDao.maxSyncedAt()
|
||||||
|
if (last != null && now() - last < STALE_WINDOW_MS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun forceRefresh(): Boolean {
|
||||||
|
refresh()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refresh() {
|
||||||
|
val parsed = withContext(Dispatchers.IO) {
|
||||||
|
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
|
||||||
|
}
|
||||||
|
val now = now()
|
||||||
|
groupDao.insertAll(
|
||||||
|
parsed.groups.map { group ->
|
||||||
|
GroupEntity(
|
||||||
|
id = group.id,
|
||||||
|
name = group.name,
|
||||||
|
faculty = group.faculty,
|
||||||
|
lastSyncedAt = now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val periods = parsed.groups.flatMap { group ->
|
||||||
|
parsed.dateRanges[group.id].orEmpty().flatMap { (season, ranges) ->
|
||||||
|
ranges.map { range ->
|
||||||
|
GroupPeriodEntity(
|
||||||
|
groupId = group.id,
|
||||||
|
season = season,
|
||||||
|
weekStart = ScheduleDates.asDate(range.first).toEpochDay(),
|
||||||
|
weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupDao.deleteAllPeriods()
|
||||||
|
groupDao.insertPeriods(periods)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun now(): Long = System.currentTimeMillis()
|
||||||
|
|
||||||
|
private fun GroupEntity.toDomain(): Group = Group(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
faculty = faculty
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package agzam.vyatsu.data.repository
|
||||||
|
|
||||||
|
import agzam.vyatsu.data.local.PreferencesDao
|
||||||
|
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||||
|
|
||||||
|
class PreferencesRepositoryImpl(
|
||||||
|
private val dao: PreferencesDao
|
||||||
|
) : PreferencesRepository {
|
||||||
|
override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
|
||||||
|
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
|
||||||
|
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup()
|
||||||
|
override suspend fun selectedWeekStart(): String? = dao.selectedWeekStart()
|
||||||
|
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
|
||||||
|
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
|
||||||
|
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
|
||||||
|
|
||||||
|
override suspend fun autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() == "true"
|
||||||
|
override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
|
||||||
|
|
||||||
|
override suspend fun autoUpdateAsked(): Boolean = dao.autoUpdateAsked() == "true"
|
||||||
|
override suspend fun setAutoUpdateAsked(asked: Boolean) = dao.setAutoUpdateAsked(asked.toString())
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package agzam.vyatsu.data.repository
|
||||||
|
|
||||||
|
import agzam.vyatsu.data.local.DayEntity
|
||||||
|
import agzam.vyatsu.data.local.GroupPeriodEntity
|
||||||
|
import agzam.vyatsu.data.local.LessonEntity
|
||||||
|
import agzam.vyatsu.data.local.ScheduleDao
|
||||||
|
import agzam.vyatsu.data.local.ScheduleEntity
|
||||||
|
import agzam.vyatsu.data.local.GroupDao
|
||||||
|
import agzam.vyatsu.data.local.GroupEntity
|
||||||
|
import agzam.vyatsu.data.remote.pdf.PdfScheduleParser
|
||||||
|
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||||
|
import agzam.vyatsu.data.remote.VyatsuUrls
|
||||||
|
import agzam.vyatsu.domain.ScheduleDates
|
||||||
|
import agzam.vyatsu.domain.model.Day
|
||||||
|
import agzam.vyatsu.domain.model.DateRange
|
||||||
|
import agzam.vyatsu.domain.model.Lesson
|
||||||
|
import agzam.vyatsu.domain.model.Schedule
|
||||||
|
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
|
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
class ScheduleRepositoryImpl(
|
||||||
|
private val scheduleDao: ScheduleDao,
|
||||||
|
private val groupDao: GroupDao
|
||||||
|
) : ScheduleRepository {
|
||||||
|
|
||||||
|
override fun observeSchedule(groupId: String): Flow<Schedule?> =
|
||||||
|
combine(
|
||||||
|
scheduleDao.observeSchedules(groupId),
|
||||||
|
scheduleDao.observeDaysForGroup(groupId),
|
||||||
|
scheduleDao.observeLessonsForGroup(groupId)
|
||||||
|
) { schedules, days, lessons ->
|
||||||
|
buildSchedule(schedules, days, lessons)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSchedule(groupId: String): Schedule? =
|
||||||
|
buildSchedule(
|
||||||
|
scheduleDao.getSchedules(groupId),
|
||||||
|
scheduleDao.getDaysForGroup(groupId),
|
||||||
|
scheduleDao.getLessonsForGroup(groupId)
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun sync(groupId: String): Schedule? {
|
||||||
|
Log.d(TAG, "sync(groupId=$groupId) start")
|
||||||
|
val group = groupDao.getById(groupId) ?: run {
|
||||||
|
Log.w(TAG, "sync: group $groupId not found in DB")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val today = ScheduleDates.moscowToday().toEpochDay()
|
||||||
|
val periods = groupDao.getCurrentAndFuturePeriods(groupId, today)
|
||||||
|
if (periods.isEmpty()) {
|
||||||
|
Log.w(TAG, "sync: no current/future periods for group ${group.id}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (period in periods) {
|
||||||
|
syncPeriod(group, period)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleDao.trim(groupId, MAX_PERIODS)
|
||||||
|
|
||||||
|
return getSchedule(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncPeriod(group: GroupEntity, period: GroupPeriodEntity) {
|
||||||
|
val season = period.season
|
||||||
|
val range = DateRange(
|
||||||
|
LocalDate.ofEpochDay(period.weekStart).format(RANGE_FMT),
|
||||||
|
LocalDate.ofEpochDay(period.weekEnd).format(RANGE_FMT)
|
||||||
|
)
|
||||||
|
val existing = scheduleDao.getSchedules(group.id)
|
||||||
|
.firstOrNull { it.season == season && it.weekStart == period.weekStart }
|
||||||
|
val isCurrent = ScheduleDates.moscowToday().toEpochDay() in period.weekStart..period.weekEnd
|
||||||
|
if (existing != null && !isCurrent && now() - existing.lastSyncedAt < STALE_WINDOW_MS) {
|
||||||
|
Log.d(TAG, "sync: period $range up to date, skip")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pdfUrl = VyatsuUrls.groupPdfUrl(group.id, season, range)
|
||||||
|
Log.d(TAG, "sync: downloading $pdfUrl")
|
||||||
|
val bytes = VyatsuHttpClient.getBytes(pdfUrl)
|
||||||
|
Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
|
||||||
|
|
||||||
|
val days = PdfScheduleParser.parse(bytes)
|
||||||
|
Log.d(TAG, "sync: parsed ${days.size} days")
|
||||||
|
|
||||||
|
val scheduleId = existing?.id ?: scheduleDao.insertSchedule(
|
||||||
|
ScheduleEntity(
|
||||||
|
groupId = group.id,
|
||||||
|
season = season,
|
||||||
|
weekStart = period.weekStart,
|
||||||
|
weekEnd = period.weekEnd,
|
||||||
|
lastSyncedAt = now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
replaceDays(scheduleId, days)
|
||||||
|
Log.d(TAG, "sync: period $range synced")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun isStale(groupId: String): Boolean {
|
||||||
|
val schedules = scheduleDao.getSchedules(groupId)
|
||||||
|
val latest = schedules.maxByOrNull { it.weekStart } ?: return true
|
||||||
|
return now() - latest.lastSyncedAt > STALE_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearPeriod(groupId: String, period: SchedulePeriod) {
|
||||||
|
scheduleDao.deleteSchedule(
|
||||||
|
groupId = groupId,
|
||||||
|
season = period.season.name,
|
||||||
|
weekStart = period.weekStart.toEpochDay()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearAll(groupId: String) = scheduleDao.deleteAll(groupId)
|
||||||
|
|
||||||
|
private suspend fun replaceDays(scheduleId: Long, days: List<Day>) {
|
||||||
|
scheduleDao.deleteDaysForSchedule(scheduleId)
|
||||||
|
val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) }
|
||||||
|
val dayIds = scheduleDao.insertDays(dayEntities)
|
||||||
|
val dayIdByDate = dayEntities.zip(dayIds).associate { (day, id) -> LocalDate.ofEpochDay(day.date) to id }
|
||||||
|
val lessons = days.flatMap { day ->
|
||||||
|
day.intervals.flatMapIndexed { slotIndex, slot ->
|
||||||
|
slot.lessons.mapIndexed { lessonIndex, lesson ->
|
||||||
|
LessonEntity(
|
||||||
|
dayId = dayIdByDate.getValue(day.date),
|
||||||
|
date = day.date.toEpochDay(),
|
||||||
|
slotIndex = slotIndex,
|
||||||
|
start = slot.start.toMinutesOfDay(),
|
||||||
|
end = slot.end.toMinutesOfDay(),
|
||||||
|
lessonIndex = lessonIndex,
|
||||||
|
raw = lesson.raw,
|
||||||
|
room = lesson.room,
|
||||||
|
groupName = lesson.group,
|
||||||
|
subgroup = lesson.subgroup,
|
||||||
|
subject = lesson.subject,
|
||||||
|
type = lesson.type,
|
||||||
|
teacher = lesson.teacher
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleDao.insertLessons(lessons)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSchedule(
|
||||||
|
schedules: List<ScheduleEntity>,
|
||||||
|
dayEntities: List<DayEntity>,
|
||||||
|
lessonEntities: List<LessonEntity>
|
||||||
|
): Schedule? {
|
||||||
|
if (schedules.isEmpty()) return null
|
||||||
|
val groupId = schedules.first().groupId
|
||||||
|
|
||||||
|
val periods = schedules.sortedBy { it.weekStart }.map {
|
||||||
|
SchedulePeriod(
|
||||||
|
weekStart = LocalDate.ofEpochDay(it.weekStart),
|
||||||
|
weekEnd = LocalDate.ofEpochDay(it.weekEnd),
|
||||||
|
season = it.season
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lessonsByDay = lessonEntities.groupBy { it.dayId }
|
||||||
|
val days = dayEntities
|
||||||
|
.sortedBy { it.date }
|
||||||
|
.distinctBy { it.date }
|
||||||
|
.map { day ->
|
||||||
|
val lessons = lessonsByDay[day.id].orEmpty()
|
||||||
|
.groupBy { it.slotIndex }
|
||||||
|
.toSortedMap()
|
||||||
|
.map { (_, slotLessons) ->
|
||||||
|
TimeSlot(
|
||||||
|
start = LocalTime.of(slotLessons.first().start / 60, slotLessons.first().start % 60),
|
||||||
|
end = LocalTime.of(slotLessons.first().end / 60, slotLessons.first().end % 60),
|
||||||
|
lessons = slotLessons
|
||||||
|
.sortedBy { it.lessonIndex }
|
||||||
|
.map { it.toDomain() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Day(date = LocalDate.ofEpochDay(day.date), intervals = lessons)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Schedule(groupId = groupId, periods = periods, days = days)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LessonEntity.toDomain(): Lesson = Lesson(
|
||||||
|
raw = raw,
|
||||||
|
room = room,
|
||||||
|
group = groupName,
|
||||||
|
subgroup = subgroup,
|
||||||
|
subject = subject,
|
||||||
|
type = type,
|
||||||
|
teacher = teacher
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
|
||||||
|
|
||||||
|
private fun now(): Long = System.currentTimeMillis()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
||||||
|
private const val MAX_PERIODS = 24
|
||||||
|
private const val TAG = "ScheduleRepo"
|
||||||
|
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package agzam.vyatsu.data.static
|
||||||
|
|
||||||
|
data class Building(
|
||||||
|
val number: Int,
|
||||||
|
val address: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
val BUILDINGS: List<Building> = listOf(
|
||||||
|
Building(1, "ул. Московская, д. 36", 58.602692, 49.666638),
|
||||||
|
Building(2, "ул. Московская, д. 39", 58.604297, 49.665926),
|
||||||
|
Building(3, "ул. Московская, д. 29", 58.604258, 49.673772),
|
||||||
|
Building(4, "ул. К.Либкнехта, д. 76 (1 корпус)", 58.602210, 49.665506),
|
||||||
|
Building(5, "ул. Карла Маркса, д. 77", 58.599995, 49.667861),
|
||||||
|
Building(6, "Студенческий проезд, д. 9", 58.605119, 49.618132),
|
||||||
|
Building(7, "ул. Преображенская, д. 32", 58.605315, 49.673662),
|
||||||
|
Building(8, "Студенческий проезд, д. 11", 58.604574, 49.618105),
|
||||||
|
Building(9, "Студенческий проезд, д. 11а", 58.604417, 49.617226),
|
||||||
|
Building(10, "ул. Ломоносова, д. 18а", 58.604644, 49.615911),
|
||||||
|
Building(11, "ул. Преображенская, д. 41", 58.605713, 49.670755),
|
||||||
|
Building(12, "ул. Ленина, д. 127", 58.586882, 49.681758),
|
||||||
|
Building(13, "ул. Красноармейская, д. 26", 58.591304, 49.676543),
|
||||||
|
Building(14, "ул. Ленина, д. 111", 58.591637, 49.680732),
|
||||||
|
Building(15, "ул. Ленина, д. 198", 58.567400, 49.686333),
|
||||||
|
Building(16, "ул. Свободы, д. 122", 58.591296, 49.678238),
|
||||||
|
Building(17, "ул. Ленина, д. 111а", 58.591030, 49.681094),
|
||||||
|
Building(18, "ул. Молодой Гвардии, д. 13", 58.598915, 49.687212),
|
||||||
|
Building(19, "ул. Орловская, д. 12", 58.596234, 49.687395),
|
||||||
|
Building(20, "ул. Ленина, д. 113", 58.590819, 49.681423),
|
||||||
|
Building(21, "ул. Ленина, д. 111а", 58.591030, 49.681094),
|
||||||
|
Building(22, "ул. Ленина, д. 111а", 58.591030, 49.681094),
|
||||||
|
Building(23, "ул. Володарского, д. 2", 58.616236, 49.671680)
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package agzam.vyatsu.data.static
|
||||||
|
|
||||||
|
data class CallTime(val start: String, val end: String)
|
||||||
|
|
||||||
|
val CALLS: List<CallTime> = listOf(
|
||||||
|
CallTime("8:20", "9:50"),
|
||||||
|
CallTime("10:00", "11:30"),
|
||||||
|
CallTime("11:45", "13:15"),
|
||||||
|
CallTime("14:00", "15:30"),
|
||||||
|
CallTime("15:45", "17:15"),
|
||||||
|
CallTime("17:20", "18:50"),
|
||||||
|
CallTime("18:55", "20:25")
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package agzam.vyatsu.di
|
||||||
|
|
||||||
|
import agzam.vyatsu.data.local.AppDatabase
|
||||||
|
import agzam.vyatsu.data.remote.GroupsParser
|
||||||
|
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||||
|
import agzam.vyatsu.data.repository.GroupRepositoryImpl
|
||||||
|
import agzam.vyatsu.data.repository.PreferencesRepositoryImpl
|
||||||
|
import agzam.vyatsu.data.repository.ScheduleRepositoryImpl
|
||||||
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
|
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||||
|
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||||
|
import agzam.vyatsu.update.AppUpdater
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
|
||||||
|
class AppContainer(context: Context) {
|
||||||
|
private val database: AppDatabase = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"vyatsu_schedule.db"
|
||||||
|
)
|
||||||
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val groupRepository: GroupRepository by lazy {
|
||||||
|
GroupRepositoryImpl(
|
||||||
|
groupDao = database.groupDao(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scheduleRepository: ScheduleRepository by lazy {
|
||||||
|
ScheduleRepositoryImpl(
|
||||||
|
scheduleDao = database.scheduleDao(),
|
||||||
|
groupDao = database.groupDao(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val preferencesRepository: PreferencesRepository by lazy {
|
||||||
|
PreferencesRepositoryImpl(database.preferencesDao())
|
||||||
|
}
|
||||||
|
|
||||||
|
val appUpdater: AppUpdater by lazy {
|
||||||
|
AppUpdater(context.applicationContext, preferencesRepository)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package agzam.vyatsu.domain
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.DateRange
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.time.temporal.TemporalAdjusters
|
||||||
|
|
||||||
|
object ScheduleDates {
|
||||||
|
private val zone: ZoneId = ZoneId.of("Europe/Moscow")
|
||||||
|
private val format: DateTimeFormatter = DateTimeFormatter.ofPattern("ddMMyyyy")
|
||||||
|
|
||||||
|
fun moscowToday(): LocalDate = LocalDate.now(zone)
|
||||||
|
|
||||||
|
fun asDate(dateStr: String): LocalDate = LocalDate.parse(dateStr, format)
|
||||||
|
|
||||||
|
fun dayIndexes(firstDate: String, today: LocalDate = moscowToday()): Pair<Int, Int> {
|
||||||
|
val begin = asDate(firstDate)
|
||||||
|
val anchor = begin.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY))
|
||||||
|
val diff = ChronoUnit.DAYS.between(anchor, today)
|
||||||
|
|
||||||
|
var week = Math.floorMod(diff / 7, 2)
|
||||||
|
var day = Math.floorMod(diff, 7)
|
||||||
|
|
||||||
|
if (day == 6) {
|
||||||
|
day = (day + 1) % 7
|
||||||
|
week = (week + 1) % 2
|
||||||
|
}
|
||||||
|
return week to day
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dateByIndexes(firstDate: String, week: Int, day: Int): LocalDate =
|
||||||
|
asDate(firstDate).plusDays((week * 7 + day).toLong())
|
||||||
|
|
||||||
|
fun currentSeason(today: LocalDate = moscowToday()): Season =
|
||||||
|
if (today.monthValue in 8..12) Season.AUTUMN else Season.SPRING
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
data class DateRange(
|
||||||
|
val first: String,
|
||||||
|
val second: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* День
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
data class Day(
|
||||||
|
|
||||||
|
/** Дата, парсится из формата dd.mm.yy из PDF **/
|
||||||
|
val date: LocalDate,
|
||||||
|
/** Список пар **/
|
||||||
|
val intervals: List<TimeSlot>,
|
||||||
|
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группа студентов ВятГУ.
|
||||||
|
* id — идентификатор группы из data-grp_period_id без последней цифры (как в PDF URL).
|
||||||
|
*/
|
||||||
|
data class Group(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val faculty: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Занятие
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
data class Lesson(
|
||||||
|
|
||||||
|
/** Сырое значение из PDF **/
|
||||||
|
val raw: String,
|
||||||
|
|
||||||
|
/** Кабинет **/
|
||||||
|
val room: String? = null,
|
||||||
|
|
||||||
|
/** Группа, например ИВТб-3304-05-00 **/
|
||||||
|
val group: String? = null,
|
||||||
|
/** Подгруппа, например на физкультуре **/
|
||||||
|
val subgroup: String? = null,
|
||||||
|
|
||||||
|
/** Предмет **/
|
||||||
|
val subject: String? = null,
|
||||||
|
|
||||||
|
/** Тип, например лекция/практика **/
|
||||||
|
val type: LessonType = LessonType.OTHER,
|
||||||
|
|
||||||
|
/** Преподаватель **/
|
||||||
|
val teacher: String? = null,
|
||||||
|
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
enum class LessonType(val label: String) {
|
||||||
|
|
||||||
|
LECTURE("Лекция"),
|
||||||
|
PRACTICE("Практическое занятие"),
|
||||||
|
LABORATORY("Лабораторная работа"),
|
||||||
|
OTHER("Занятие");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromRaw(raw: String): LessonType = when (raw.trim()) {
|
||||||
|
"Лекция" -> LECTURE
|
||||||
|
"Практическое занятие" -> PRACTICE
|
||||||
|
"Лабораторная работа" -> LABORATORY
|
||||||
|
else -> OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
data class SchedulePeriod(
|
||||||
|
val weekStart: LocalDate,
|
||||||
|
val weekEnd: LocalDate,
|
||||||
|
val season: Season
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Schedule(
|
||||||
|
val groupId: String,
|
||||||
|
val periods: List<SchedulePeriod>,
|
||||||
|
val days: List<Day>
|
||||||
|
) {
|
||||||
|
fun periodOf(date: LocalDate): SchedulePeriod? =
|
||||||
|
periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) }
|
||||||
|
|
||||||
|
fun daysOf(period: SchedulePeriod): List<Day> =
|
||||||
|
days.filter { !it.date.isBefore(period.weekStart) && !it.date.isAfter(period.weekEnd) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
enum class Season {
|
||||||
|
AUTUMN, SPRING;
|
||||||
|
|
||||||
|
val apiKey: String
|
||||||
|
get() = when (this) {
|
||||||
|
AUTUMN -> "1"
|
||||||
|
SPRING -> "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Временной промежуток/пара (например 08:20-09:30 из PDF)
|
||||||
|
*
|
||||||
|
* Может содержать несколько [Lesson], например для разделения на подгруппы
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
data class TimeSlot(
|
||||||
|
|
||||||
|
val start: LocalTime,
|
||||||
|
val end: LocalTime,
|
||||||
|
val lessons: List<Lesson>
|
||||||
|
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package agzam.vyatsu.domain.repository
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Group
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface GroupRepository {
|
||||||
|
fun observeGroups(): Flow<List<Group>>
|
||||||
|
suspend fun getGroups(): List<Group>
|
||||||
|
suspend fun getFaculties(): List<String>
|
||||||
|
suspend fun refreshIfStale(): Boolean
|
||||||
|
suspend fun forceRefresh(): Boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package agzam.vyatsu.domain.repository
|
||||||
|
|
||||||
|
interface PreferencesRepository {
|
||||||
|
suspend fun selectedGroupId(): String?
|
||||||
|
suspend fun setSelectedGroup(groupId: String)
|
||||||
|
suspend fun clearSelectedGroup()
|
||||||
|
suspend fun selectedWeekStart(): String?
|
||||||
|
suspend fun setSelectedWeekStart(weekStart: String)
|
||||||
|
suspend fun selectedDayDate(): String?
|
||||||
|
suspend fun setSelectedDayDate(date: String)
|
||||||
|
|
||||||
|
suspend fun autoUpdateEnabled(): Boolean
|
||||||
|
suspend fun setAutoUpdateEnabled(enabled: Boolean)
|
||||||
|
|
||||||
|
suspend fun autoUpdateAsked(): Boolean
|
||||||
|
suspend fun setAutoUpdateAsked(asked: Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package agzam.vyatsu.domain.repository
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Schedule
|
||||||
|
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface ScheduleRepository {
|
||||||
|
fun observeSchedule(groupId: String): Flow<Schedule?>
|
||||||
|
suspend fun getSchedule(groupId: String): Schedule?
|
||||||
|
suspend fun sync(groupId: String): Schedule?
|
||||||
|
suspend fun isStale(groupId: String): Boolean
|
||||||
|
suspend fun clearPeriod(groupId: String, period: SchedulePeriod)
|
||||||
|
suspend fun clearAll(groupId: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package agzam.vyatsu.ui.groups
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Group
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
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.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SearchBar
|
||||||
|
import androidx.compose.material3.SearchBarDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница выбора группы
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GroupPickerScreen(viewModel: GroupPickerViewModel, onGroupPicked: (Group) -> Unit) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.windowInsetsPadding(WindowInsets.systemBars)
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
if (!isSearchActive) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Выберите группу",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(onClick = { viewModel.refresh() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading && state.groups.isEmpty()) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
if (state.error != null && state.groups.isEmpty()) {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text(state.error.orEmpty(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||||
|
TextButton(onClick = { viewModel.retry() }) { Text("Повторить") }
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
SearchBar(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
expanded = isSearchActive,
|
||||||
|
onExpandedChange = { isSearchActive = it },
|
||||||
|
inputField = {
|
||||||
|
SearchBarDefaults.InputField(
|
||||||
|
query = state.searchQuery,
|
||||||
|
onQueryChange = { viewModel.search(it) },
|
||||||
|
onSearch = { isSearchActive = false },
|
||||||
|
expanded = isSearchActive,
|
||||||
|
onExpandedChange = { isSearchActive = it },
|
||||||
|
placeholder = { Text("Поиск группы") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (isSearchActive && state.searchQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.search("") }) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = "Очистить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
GroupList(
|
||||||
|
groups = state.filteredGroups,
|
||||||
|
onPick = { group ->
|
||||||
|
isSearchActive = false
|
||||||
|
onGroupPicked(group)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSearchActive) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
GroupList(state.filteredGroups, onGroupPicked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(groups) { group ->
|
||||||
|
Column(modifier = Modifier.fillMaxWidth() .clickable { onPick(group) }) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).padding(horizontal = 16.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
// XXX: стоит ли добавлять факультет или это засорит интерфейс
|
||||||
|
// Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
|
||||||
|
// if (group.faculty.isNotEmpty()) Text(text = group.faculty + " · ", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package agzam.vyatsu.ui.groups
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Group
|
||||||
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
class GroupPickerViewModel(private val repository: GroupRepository) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(GroupPickerUiState())
|
||||||
|
val uiState: StateFlow<GroupPickerUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val ok = try {
|
||||||
|
repository.refreshIfStale()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = e.message ?: "Не удалось загрузить группы"
|
||||||
|
)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!ok && _uiState.value.groups.isEmpty()) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = _uiState.value.error ?: "Не удалось обновить список групп"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repository.observeGroups().collect { groups ->
|
||||||
|
val query = _uiState.value.searchQuery
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
groups = groups,
|
||||||
|
filteredGroups = filterGroups(groups, query),
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(query: String) {
|
||||||
|
val groups = _uiState.value.groups
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
searchQuery = query,
|
||||||
|
filteredGroups = filterGroups(groups, query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
|
||||||
|
if (query.isBlank()) return groups
|
||||||
|
val lowerQuery = query.lowercase()
|
||||||
|
return groups.filter { group ->
|
||||||
|
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retry() {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
repository.forceRefresh()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Не удалось обновить список групп"
|
||||||
|
)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class GroupPickerUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
val groups: List<Group> = emptyList(),
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val filteredGroups: List<Group> = emptyList()
|
||||||
|
)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package agzam.vyatsu.ui.home
|
||||||
|
|
||||||
|
import agzam.vyatsu.ui.info.BuildingsScreen
|
||||||
|
import agzam.vyatsu.ui.info.CallsScreen
|
||||||
|
import agzam.vyatsu.ui.schedule.ScheduleScreen
|
||||||
|
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||||
|
import agzam.vyatsu.ui.settings.SettingsScreen
|
||||||
|
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
private enum class HomeTab(val label: String) {
|
||||||
|
SCHEDULE("Расписание"),
|
||||||
|
CALLS("Звонки"),
|
||||||
|
BUILDINGS("Корпуса"),
|
||||||
|
SETTINGS("Настройки")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
scheduleViewModel: ScheduleViewModel,
|
||||||
|
updateViewModel: UpdateViewModel,
|
||||||
|
onSwitchGroup: () -> Unit
|
||||||
|
) {
|
||||||
|
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
HomeTab.entries.forEach { t ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = tab == t,
|
||||||
|
onClick = { tab = t },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (t) {
|
||||||
|
HomeTab.SCHEDULE -> Icons.Default.DateRange
|
||||||
|
HomeTab.CALLS -> Icons.Default.Info
|
||||||
|
HomeTab.BUILDINGS -> Icons.Default.Home
|
||||||
|
HomeTab.SETTINGS -> Icons.Default.Settings
|
||||||
|
},
|
||||||
|
contentDescription = t.label
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(t.label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
when (tab) {
|
||||||
|
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||||
|
HomeTab.CALLS -> CallsScreen()
|
||||||
|
HomeTab.BUILDINGS -> BuildingsScreen()
|
||||||
|
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package agzam.vyatsu.ui.info
|
||||||
|
|
||||||
|
import agzam.vyatsu.data.static.BUILDINGS
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import agzam.vyatsu.BuildConfig
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BuildingsScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
LazyColumn(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
) {
|
||||||
|
items(BUILDINGS) { b ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
val uri = Uri.parse("geo:${b.latitude},${b.longitude}?q=${b.latitude},${b.longitude}(Корпус ${b.number})")
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Корпус ${b.number}",
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
b.address,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package agzam.vyatsu.ui.info
|
||||||
|
|
||||||
|
import agzam.vyatsu.data.static.CALLS
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallsScreen() {
|
||||||
|
LazyColumn(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
) {
|
||||||
|
itemsIndexed(CALLS) { index, call ->
|
||||||
|
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
"${index + 1} пара",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${call.start} – ${call.end}",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
package agzam.vyatsu.ui.schedule
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Day
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
|
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||||
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||||
|
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
|
||||||
|
val hPadding = 16.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||||
|
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
||||||
|
actions = {
|
||||||
|
if (state.isLoading && state.schedule != null) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { onSwitchGroup() }) {
|
||||||
|
Text("Сменить группу")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.refresh() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
windowInsets = WindowInsets(0.dp)
|
||||||
|
)
|
||||||
|
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
|
||||||
|
when {
|
||||||
|
state.schedule == null && state.loadError -> {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Не удалось загрузить расписание",
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
TextButton(onClick = { viewModel.refresh() }) {
|
||||||
|
Text("Повторить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.schedule == null -> {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val schedule = state.schedule!!
|
||||||
|
val weekDays = state.weekStart
|
||||||
|
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
|
||||||
|
?.let { schedule.daysOf(it) }
|
||||||
|
.orEmpty()
|
||||||
|
val day = schedule.days.firstOrNull { it.date == state.dayDate }
|
||||||
|
|
||||||
|
if (day == null) {
|
||||||
|
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
} else {
|
||||||
|
|
||||||
|
val schedule = state.schedule!!
|
||||||
|
val weekDays = state.weekStart
|
||||||
|
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
|
||||||
|
?.let { schedule.daysOf(it) }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0)
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = selectedDayIndex,
|
||||||
|
pageCount = { weekDays.size }
|
||||||
|
)
|
||||||
|
LaunchedEffect(state.dayDate) {
|
||||||
|
val targetPage = weekDays.indexOfFirst { it.date == state.dayDate }
|
||||||
|
if (targetPage != -1 && targetPage != pagerState.currentPage) {
|
||||||
|
pagerState.scrollToPage(targetPage) // Мгновенное переключение без анимации
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(pagerState) {
|
||||||
|
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||||
|
if (pagerState.isScrollInProgress && page in weekDays.indices) {
|
||||||
|
viewModel.selectDay(weekDays[page].date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isTodayDay = day.date == state.todayDate
|
||||||
|
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = hPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
WeekSelector(
|
||||||
|
periods = schedule.periods,
|
||||||
|
selectedWeekStart = state.weekStart,
|
||||||
|
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
|
||||||
|
onSelect = viewModel::selectWeek
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
|
||||||
|
|
||||||
|
DayChips(
|
||||||
|
days = weekDays,
|
||||||
|
selectedDate = state.dayDate,
|
||||||
|
todayDate = state.todayDate,
|
||||||
|
onSelect = viewModel::selectDay,
|
||||||
|
pagerState = pagerState
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
|
||||||
|
|
||||||
|
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
|
||||||
|
val day = weekDays.getOrNull(page)
|
||||||
|
|
||||||
|
if (day == null || day.intervals.all { it.lessons.isEmpty() }) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(day.intervals) { slotIndex, slot ->
|
||||||
|
LessonSlot(slotIndex = slotIndex, slot = slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun WeekSelector(
|
||||||
|
periods: List<SchedulePeriod>,
|
||||||
|
selectedWeekStart: LocalDate?,
|
||||||
|
todayWeekStart: LocalDate?,
|
||||||
|
onSelect: (LocalDate) -> Unit
|
||||||
|
) {
|
||||||
|
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.clickable(enabled = true, onClick = { expanded = true })
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
selected?.let(::weekLabel) ?: "Период",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
modifier = Modifier.widthIn(min = 220.dp)
|
||||||
|
) {
|
||||||
|
periods.forEach { period ->
|
||||||
|
val isTodayWeek = todayWeekStart == period.weekStart
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
weekLabel(period),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = if (isTodayWeek) {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
"сегодня",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelect(period.weekStart)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getVisiblePercentage(item: LazyListItemInfo, layoutInfo: LazyListLayoutInfo): Float {
|
||||||
|
val viewportStart = layoutInfo.viewportStartOffset
|
||||||
|
val viewportEnd = layoutInfo.viewportEndOffset
|
||||||
|
|
||||||
|
val itemStart = item.offset
|
||||||
|
val itemEnd = item.offset + item.size
|
||||||
|
|
||||||
|
val visibleStart = max(itemStart, viewportStart)
|
||||||
|
val visibleEnd = min(itemEnd, viewportEnd)
|
||||||
|
val visibleSize = visibleEnd - visibleStart
|
||||||
|
|
||||||
|
return if (visibleSize <= 0) 0f else visibleSize.toFloat() / item.size
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayChips(
|
||||||
|
days: List<Day>,
|
||||||
|
selectedDate: LocalDate?,
|
||||||
|
todayDate: LocalDate?,
|
||||||
|
pagerState: PagerState,
|
||||||
|
onSelect: (LocalDate) -> Unit
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val paddingPx = with(density) { hPadding.toPx() }
|
||||||
|
|
||||||
|
|
||||||
|
LaunchedEffect(pagerState, days) {
|
||||||
|
snapshotFlow {
|
||||||
|
pagerState.currentPage
|
||||||
|
}.collectLatest { targetPage ->
|
||||||
|
|
||||||
|
if (targetPage in days.indices) {
|
||||||
|
val layoutInfo = listState.layoutInfo
|
||||||
|
val visibleItems = layoutInfo.visibleItemsInfo
|
||||||
|
val targetItem = visibleItems.firstOrNull { it.index == targetPage }
|
||||||
|
|
||||||
|
if (targetItem != null) {
|
||||||
|
val visiblePercentage = getVisiblePercentage(targetItem, layoutInfo)
|
||||||
|
val minVisibleThreshold = 0.9f
|
||||||
|
|
||||||
|
if (visiblePercentage < minVisibleThreshold) {
|
||||||
|
val viewPortWidth = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
|
||||||
|
val halfViewport = viewPortWidth / 2f
|
||||||
|
val centerOffset = (halfViewport - (targetItem.size / 2f) - paddingPx).toInt()
|
||||||
|
|
||||||
|
listState.animateScrollToItem(index = targetPage, scrollOffset = -centerOffset)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val viewPortWidth = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
|
||||||
|
val halfViewport = viewPortWidth / 2f
|
||||||
|
|
||||||
|
val averageSize = visibleItems.map { it.size }.average().toInt().coerceAtLeast(150)
|
||||||
|
val centerOffset = (halfViewport - (averageSize / 2f) - paddingPx).toInt()
|
||||||
|
|
||||||
|
listState.animateScrollToItem(index = targetPage, scrollOffset = -centerOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyRow(state = listState, horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
|
||||||
|
items(days, key = { it.date.toEpochDay() }) { day ->
|
||||||
|
val selected = selectedDate == day.date
|
||||||
|
val isTodayChip = todayDate == day.date
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { onSelect(day.date) },
|
||||||
|
label = {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
dayName(day.date.dayOfWeek),
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
day.date.format(CHIP_DATE),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
border = if (isTodayChip && !selected) {
|
||||||
|
FilterChipDefaults.filterChipBorder(
|
||||||
|
borderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
borderWidth = 1.dp,
|
||||||
|
enabled = true,
|
||||||
|
selected = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weekLabel(period: SchedulePeriod): String {
|
||||||
|
val first = period.weekStart
|
||||||
|
return "с ${first.format(CHIP_DATE)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var indexColor = 0
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.width(10.dp + hPadding).padding(start = hPadding),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f).padding(end = hPadding))
|
||||||
|
}
|
||||||
|
if (slot.lessons.isEmpty()) {
|
||||||
|
Text("—", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
} else {
|
||||||
|
slot.lessons.forEachIndexed { index, lesson ->
|
||||||
|
var baseSurface = Modifier.fillMaxWidth().padding(vertical = 2.dp)
|
||||||
|
if ((indexColor) % 2 != 0) {
|
||||||
|
baseSurface = baseSurface.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
indexColor++
|
||||||
|
// XXX:
|
||||||
|
// Убрать SelectionContainer?
|
||||||
|
// Режим редактирования расписания?
|
||||||
|
// Меню на onClick?
|
||||||
|
// Меню на onLongClick?
|
||||||
|
SelectionContainer() {
|
||||||
|
Column(
|
||||||
|
baseSurface
|
||||||
|
// XXX: combinedClickable?
|
||||||
|
//.combinedClickable(
|
||||||
|
// onClick = {
|
||||||
|
// Toast.makeText(
|
||||||
|
// context,
|
||||||
|
// lesson.raw,
|
||||||
|
// Toast.LENGTH_SHORT
|
||||||
|
// ).show()
|
||||||
|
// },
|
||||||
|
// onLongClick = {},
|
||||||
|
// // TODO: onLongClickLabel = "(Accessibility)"
|
||||||
|
//),
|
||||||
|
) {
|
||||||
|
var any = false
|
||||||
|
|
||||||
|
val meta = listOfNotNull(
|
||||||
|
if (lesson.type == LessonType.OTHER) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
lesson.type.label
|
||||||
|
},
|
||||||
|
lesson.subgroup,
|
||||||
|
lesson.teacher,
|
||||||
|
lesson.room
|
||||||
|
).filter { it.isNotBlank() }.joinToString(" · ")
|
||||||
|
|
||||||
|
if (meta.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
meta,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = hPadding)
|
||||||
|
)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
|
||||||
|
if (subject.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
subject,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(horizontal = hPadding)
|
||||||
|
)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!any) {
|
||||||
|
Text(
|
||||||
|
"пусто",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = hPadding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
|
||||||
|
DayOfWeek.MONDAY -> "Пн"
|
||||||
|
DayOfWeek.TUESDAY -> "Вт"
|
||||||
|
DayOfWeek.WEDNESDAY -> "Ср"
|
||||||
|
DayOfWeek.THURSDAY -> "Чт"
|
||||||
|
DayOfWeek.FRIDAY -> "Пт"
|
||||||
|
DayOfWeek.SATURDAY -> "Сб"
|
||||||
|
DayOfWeek.SUNDAY -> "Вс"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
|
private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
||||||
|
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package agzam.vyatsu.ui.schedule
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.ScheduleDates
|
||||||
|
import agzam.vyatsu.domain.model.Schedule
|
||||||
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
|
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||||
|
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
class ScheduleViewModel(
|
||||||
|
private val scheduleRepository: ScheduleRepository,
|
||||||
|
private val preferencesRepository: PreferencesRepository,
|
||||||
|
private val groupRepository: GroupRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||||
|
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var currentGroupId: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val groupId = preferencesRepository.selectedGroupId()
|
||||||
|
if (groupId != null) {
|
||||||
|
selectGroup(groupId)
|
||||||
|
} else {
|
||||||
|
_uiState.value = _uiState.value.copy(noGroup = true, groupName = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectGroup(groupId: String) {
|
||||||
|
currentGroupId = groupId
|
||||||
|
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.setSelectedGroup(groupId)
|
||||||
|
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
||||||
|
|
||||||
|
val savedWeekStart = runCatching {
|
||||||
|
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
|
||||||
|
}.getOrNull()
|
||||||
|
val savedDayDate = runCatching {
|
||||||
|
preferencesRepository.selectedDayDate()?.let(LocalDate::parse)
|
||||||
|
}.getOrNull()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
noGroup = false,
|
||||||
|
groupName = groupName,
|
||||||
|
weekStart = savedWeekStart,
|
||||||
|
dayDate = savedDayDate
|
||||||
|
)
|
||||||
|
|
||||||
|
launch {
|
||||||
|
scheduleRepository.observeSchedule(groupId).collect { schedule ->
|
||||||
|
currentGroupId?.let { applySchedule(schedule) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
syncIfMissing(groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applySchedule(schedule: Schedule?) {
|
||||||
|
val current = _uiState.value
|
||||||
|
if (schedule == null || schedule.periods.isEmpty()) {
|
||||||
|
_uiState.value = current.copy(schedule = schedule, todayDate = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val today = ScheduleDates.moscowToday()
|
||||||
|
val todayDate = if (schedule.periodOf(today) != null) today else null
|
||||||
|
|
||||||
|
val weekStart = when {
|
||||||
|
schedule.periods.any { it.weekStart == current.weekStart } -> current.weekStart!!
|
||||||
|
todayDate != null -> schedule.periodOf(todayDate)!!.weekStart
|
||||||
|
else -> schedule.periods.first().weekStart
|
||||||
|
}
|
||||||
|
val period = schedule.periods.first { it.weekStart == weekStart }
|
||||||
|
val weekDays = schedule.daysOf(period)
|
||||||
|
|
||||||
|
val dayDate = when {
|
||||||
|
weekDays.any { it.date == current.dayDate } -> current.dayDate
|
||||||
|
weekDays.any { it.date == todayDate } -> todayDate
|
||||||
|
else -> weekDays.firstOrNull()?.date
|
||||||
|
}
|
||||||
|
|
||||||
|
preferencesRepository.setSelectedWeekStart(weekStart.toString())
|
||||||
|
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
|
||||||
|
|
||||||
|
_uiState.value = current.copy(
|
||||||
|
schedule = schedule,
|
||||||
|
weekStart = weekStart,
|
||||||
|
dayDate = dayDate,
|
||||||
|
todayDate = todayDate,
|
||||||
|
isLoading = false,
|
||||||
|
loadError = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectWeek(weekStart: LocalDate) {
|
||||||
|
val state = _uiState.value
|
||||||
|
val schedule = state.schedule ?: return
|
||||||
|
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val today = ScheduleDates.moscowToday()
|
||||||
|
val weekDays = schedule.daysOf(period)
|
||||||
|
val dayDate = when {
|
||||||
|
weekDays.any { it.date == today } -> today
|
||||||
|
else -> weekDays.firstOrNull()?.date
|
||||||
|
}
|
||||||
|
preferencesRepository.setSelectedWeekStart(weekStart.toString())
|
||||||
|
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
|
||||||
|
_uiState.value = _uiState.value.copy(weekStart = weekStart, dayDate = dayDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectDay(date: LocalDate) {
|
||||||
|
val state = _uiState.value
|
||||||
|
val schedule = state.schedule ?: return
|
||||||
|
val period = schedule.periodOf(date) ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.setSelectedWeekStart(period.weekStart.toString())
|
||||||
|
preferencesRepository.setSelectedDayDate(date.toString())
|
||||||
|
_uiState.value = _uiState.value.copy(weekStart = period.weekStart, dayDate = date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncIfMissing(groupId: String) {
|
||||||
|
if (scheduleRepository.getSchedule(groupId) != null) {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadSchedule(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadSchedule(groupId: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||||
|
val schedule = scheduleRepository.sync(groupId)
|
||||||
|
_uiState.value = if (schedule != null) {
|
||||||
|
_uiState.value.copy(isLoading = false, loadError = false)
|
||||||
|
} else {
|
||||||
|
_uiState.value.copy(isLoading = false, loadError = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchGroup() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
currentGroupId = null
|
||||||
|
preferencesRepository.clearSelectedGroup()
|
||||||
|
_uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
val groupId = currentGroupId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||||
|
// Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
|
||||||
|
runCatching { groupRepository.forceRefresh() }
|
||||||
|
loadSchedule(groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
val groupId = currentGroupId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
scheduleRepository.clearAll(groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelectedPeriod() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val schedule = state.schedule ?: return
|
||||||
|
val weekStart = state.weekStart ?: return
|
||||||
|
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
scheduleRepository.clearPeriod(schedule.groupId, period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ScheduleUiState(
|
||||||
|
val noGroup: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val loadError: Boolean = false,
|
||||||
|
val schedule: Schedule? = null,
|
||||||
|
val groupName: String? = null,
|
||||||
|
val weekStart: LocalDate? = null,
|
||||||
|
val dayDate: LocalDate? = null,
|
||||||
|
val todayDate: LocalDate? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package agzam.vyatsu.ui.settings
|
||||||
|
|
||||||
|
import agzam.vyatsu.BuildConfig
|
||||||
|
import agzam.vyatsu.update.UpdateState
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(viewModel: UpdateViewModel) {
|
||||||
|
val autoEnabled by viewModel.autoEnabled.collectAsState()
|
||||||
|
val updateState by viewModel.updateState.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
) {
|
||||||
|
item { SectionHeader("Обновления") }
|
||||||
|
item {
|
||||||
|
SettingsItem(
|
||||||
|
title = "Автоматические обновления",
|
||||||
|
subtitle = "Проверять и устанавливать новые версии при запуске",
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = autoEnabled,
|
||||||
|
onCheckedChange = { viewModel.setAutoEnabled(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SettingsItem(
|
||||||
|
title = "Проверить наличие обновлений",
|
||||||
|
subtitle = updateStatusText(updateState, autoEnabled),
|
||||||
|
onClick = { viewModel.checkNow() }
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
}
|
||||||
|
if (autoEnabled && (updateState is UpdateState.Available || updateState is UpdateState.ReadyToInstall)) {
|
||||||
|
item {
|
||||||
|
SettingsItem(
|
||||||
|
title = "Установить обновление",
|
||||||
|
subtitle = installHint(updateState, viewModel),
|
||||||
|
onClick = {
|
||||||
|
when (updateState) {
|
||||||
|
is UpdateState.Available -> viewModel.downloadAndInstall()
|
||||||
|
is UpdateState.ReadyToInstall -> viewModel.installDownloadedApk()
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { SectionHeader("О приложении") }
|
||||||
|
item {
|
||||||
|
SettingsItem(
|
||||||
|
title = "Версия",
|
||||||
|
trailing = {
|
||||||
|
Text(
|
||||||
|
BuildConfig.VERSION_NAME,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStatusText(state: UpdateState, autoEnabled: Boolean): String = when (state) {
|
||||||
|
is UpdateState.Idle -> "Проверить сейчас"
|
||||||
|
is UpdateState.Checking -> "Проверка…"
|
||||||
|
is UpdateState.UpToDate -> "Установлена последняя версия"
|
||||||
|
is UpdateState.Available ->
|
||||||
|
if (autoEnabled) "Доступна версия ${state.version}"
|
||||||
|
else "Доступна версия ${state.version} — включите автообновления для установки"
|
||||||
|
is UpdateState.Downloading -> "Скачивание: ${state.progress?.let { "$it%" } ?: "…"}"
|
||||||
|
is UpdateState.ReadyToInstall -> "Обновление скачано, нажмите для установки"
|
||||||
|
is UpdateState.Failed -> "Ошибка: ${state.message}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installHint(state: UpdateState, viewModel: UpdateViewModel): String = when {
|
||||||
|
state is UpdateState.ReadyToInstall && !viewModel.canRequestInstalls() ->
|
||||||
|
"Разрешите установку приложений из этого источника"
|
||||||
|
else -> "Нажмите, чтобы продолжить"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(title: String) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsItem(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
onClick: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailing?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsDivider() {
|
||||||
|
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package agzam.vyatsu.ui.settings
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||||
|
import agzam.vyatsu.update.AppUpdater
|
||||||
|
import agzam.vyatsu.update.UpdateState
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class UpdateViewModel(
|
||||||
|
private val updater: AppUpdater,
|
||||||
|
private val preferencesRepository: PreferencesRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val updateState: StateFlow<UpdateState> = updater.state
|
||||||
|
|
||||||
|
private val _autoEnabled = MutableStateFlow(false)
|
||||||
|
val autoEnabled: StateFlow<Boolean> = _autoEnabled.asStateFlow()
|
||||||
|
|
||||||
|
private val _showFirstRunDialog = MutableStateFlow(false)
|
||||||
|
val showFirstRunDialog: StateFlow<Boolean> = _showFirstRunDialog.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_autoEnabled.value = preferencesRepository.autoUpdateEnabled()
|
||||||
|
_showFirstRunDialog.value = !preferencesRepository.autoUpdateAsked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutoEnabled(enabled: Boolean) {
|
||||||
|
_autoEnabled.value = enabled
|
||||||
|
updater.setAutoEnabled(enabled)
|
||||||
|
if (enabled && !updater.canRequestInstalls()) {
|
||||||
|
updater.openUnknownSourcesSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFirstRunDecision(enable: Boolean) {
|
||||||
|
_showFirstRunDialog.value = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.setAutoUpdateAsked(true)
|
||||||
|
}
|
||||||
|
setAutoEnabled(enable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkNow() = updater.checkForUpdate()
|
||||||
|
|
||||||
|
fun downloadAndInstall() = updater.downloadAndInstall()
|
||||||
|
|
||||||
|
fun installDownloadedApk() = updater.installDownloadedApk()
|
||||||
|
|
||||||
|
fun canRequestInstalls() = updater.canRequestInstalls()
|
||||||
|
|
||||||
|
fun openUnknownSourcesSettings() = updater.openUnknownSourcesSettings()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package agzam.vyatsu.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package agzam.vyatsu.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Purple80,
|
||||||
|
secondary = PurpleGrey80,
|
||||||
|
tertiary = Pink80
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Purple40,
|
||||||
|
secondary = PurpleGrey40,
|
||||||
|
tertiary = Pink40
|
||||||
|
|
||||||
|
/* Other default colors to override
|
||||||
|
background = Color(0xFFFFFBFE),
|
||||||
|
surface = Color(0xFFFFFBFE),
|
||||||
|
onPrimary = Color.White,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
onBackground = Color(0xFF1C1B1F),
|
||||||
|
onSurface = Color(0xFF1C1B1F),
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VyatsuScheduleTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
// Dynamic color is available on Android 12+
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package agzam.vyatsu.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Set of Material typography styles to start with
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
/* Other default text styles to override
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
)
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package agzam.vyatsu.update
|
||||||
|
|
||||||
|
import agzam.vyatsu.BuildConfig
|
||||||
|
import agzam.vyatsu.data.remote.update.GiteaReleaseApi
|
||||||
|
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
sealed interface UpdateState {
|
||||||
|
data object Idle : UpdateState
|
||||||
|
data object Checking : UpdateState
|
||||||
|
data object UpToDate : UpdateState
|
||||||
|
data class Available(val version: String, val notes: String?) : UpdateState
|
||||||
|
data class Downloading(val progress: Int?) : UpdateState
|
||||||
|
data object ReadyToInstall : UpdateState
|
||||||
|
data class Failed(val message: String) : UpdateState
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppUpdater(
|
||||||
|
private val context: Context,
|
||||||
|
private val preferencesRepository: PreferencesRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val api = GiteaReleaseApi(client)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UpdateState>(UpdateState.Idle)
|
||||||
|
val state: StateFlow<UpdateState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var job: Job? = null
|
||||||
|
private var pendingUpdate: AvailableUpdate? = null
|
||||||
|
private var downloadedApk: File? = null
|
||||||
|
|
||||||
|
fun autoCheckAndInstall() {
|
||||||
|
if (job?.isActive == true) return
|
||||||
|
job = scope.launch {
|
||||||
|
if (!preferencesRepository.autoUpdateEnabled()) return@launch
|
||||||
|
runCatching {
|
||||||
|
val update = check() ?: return@launch
|
||||||
|
install(download(update))
|
||||||
|
}.onFailure(::onUpdateFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkForUpdate() {
|
||||||
|
if (job?.isActive == true) return
|
||||||
|
job = scope.launch {
|
||||||
|
runCatching { check() }.onFailure(::onUpdateFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadAndInstall() {
|
||||||
|
if (job?.isActive == true) return
|
||||||
|
val update = pendingUpdate ?: return
|
||||||
|
job = scope.launch {
|
||||||
|
if (!preferencesRepository.autoUpdateEnabled()) return@launch
|
||||||
|
runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installDownloadedApk() {
|
||||||
|
val apk = downloadedApk ?: return
|
||||||
|
install(apk)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutoEnabled(enabled: Boolean) {
|
||||||
|
if (!enabled) {
|
||||||
|
job?.cancel()
|
||||||
|
if (_state.value is UpdateState.Downloading || _state.value is UpdateState.Checking) {
|
||||||
|
_state.value = UpdateState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
preferencesRepository.setAutoUpdateEnabled(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canRequestInstalls(): Boolean =
|
||||||
|
context.packageManager.canRequestPackageInstalls()
|
||||||
|
|
||||||
|
fun openUnknownSourcesSettings() {
|
||||||
|
val intent = Intent(
|
||||||
|
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:${context.packageName}")
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun check(): AvailableUpdate? {
|
||||||
|
_state.value = UpdateState.Checking
|
||||||
|
val release = api.latestRelease()
|
||||||
|
if (release == null) {
|
||||||
|
_state.value = UpdateState.UpToDate
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val remoteVersion = release.tagName?.trimStart('v', 'V')
|
||||||
|
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME.trimStart('v', 'V'))) {
|
||||||
|
_state.value = UpdateState.UpToDate
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val apkUrl = api.apkDownloadUrl(release)
|
||||||
|
?: throw IOException("В релизе $remoteVersion нет APK-файла")
|
||||||
|
|
||||||
|
val update = AvailableUpdate(version = remoteVersion, apkUrl = apkUrl)
|
||||||
|
pendingUpdate = update
|
||||||
|
_state.value = UpdateState.Available(update.version, release.body)
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun download(update: AvailableUpdate): File {
|
||||||
|
_state.value = UpdateState.Downloading(null)
|
||||||
|
|
||||||
|
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||||
|
val target = File(dir, "update-${update.version}.apk")
|
||||||
|
downloadedApk?.delete()
|
||||||
|
|
||||||
|
val response = client.newCall(Request.Builder().url(update.apkUrl).get().build()).execute()
|
||||||
|
response.use { resp ->
|
||||||
|
if (!resp.isSuccessful) throw IOException("HTTP ${resp.code} при скачивании")
|
||||||
|
val body = resp.body ?: throw IOException("Пустой ответ при скачивании")
|
||||||
|
val contentLength = body.contentLength()
|
||||||
|
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
target.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE_BYTES)
|
||||||
|
var readTotal = 0L
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
readTotal += read
|
||||||
|
if (contentLength > 0) {
|
||||||
|
_state.value = UpdateState.Downloading((readTotal * 100 / contentLength).toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.length() == 0L) {
|
||||||
|
target.delete()
|
||||||
|
throw IOException("Скачанный файл пуст")
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadedApk = target
|
||||||
|
_state.value = UpdateState.ReadyToInstall
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun install(apk: File) {
|
||||||
|
if (!canRequestInstalls()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
apk
|
||||||
|
)
|
||||||
|
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
|
||||||
|
data = uri
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
.onFailure { Log.e(TAG, "Не удалось запустить установку", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onUpdateFailed(error: Throwable) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
Log.e(TAG, "Ошибка обновления", error)
|
||||||
|
_state.value = UpdateState.Failed(error.message ?: "Неизвестная ошибка")
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AvailableUpdate(val version: String, val apkUrl: String)
|
||||||
|
|
||||||
|
private fun isNewerVersion(remote: String, local: String): Boolean {
|
||||||
|
val remoteParts = remote.split('.').map { it.toLongOrNull() ?: 0L }
|
||||||
|
val localParts = local.split('.').map { it.toLongOrNull() ?: 0L }
|
||||||
|
val size = maxOf(remoteParts.size, localParts.size)
|
||||||
|
for (i in 0 until size) {
|
||||||
|
val r = remoteParts.getOrElse(i) { 0L }
|
||||||
|
val l = localParts.getOrElse(i) { 0L }
|
||||||
|
if (r != l) return r > l
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "AppUpdate"
|
||||||
|
const val BUFFER_SIZE_BYTES = 8 * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="100dp"
|
||||||
|
android:height="100dp"
|
||||||
|
android:viewportWidth="20"
|
||||||
|
android:viewportHeight="20">
|
||||||
|
<path
|
||||||
|
android:pathData="M1,6L1,18L19,18L19,6"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="0"
|
||||||
|
android:strokeLineCap="round">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="1"
|
||||||
|
android:startY="18"
|
||||||
|
android:endX="19"
|
||||||
|
android:endY="6"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF5581D5"/>
|
||||||
|
<item android:offset="1" android:color="#FF4063CD"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
<aapt:attr name="android:strokeColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="1"
|
||||||
|
android:startY="18"
|
||||||
|
android:endX="19"
|
||||||
|
android:endY="6"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF5581D5"/>
|
||||||
|
<item android:offset="1" android:color="#FF4063CD"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M19,18L1,18C12,18 19,14 19,6Z"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="0"
|
||||||
|
android:strokeLineCap="round">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="1"
|
||||||
|
android:startY="18"
|
||||||
|
android:endX="19"
|
||||||
|
android:endY="6"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF6598E3"/>
|
||||||
|
<item android:offset="1" android:color="#FF74A2E6"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
<aapt:attr name="android:strokeColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="1"
|
||||||
|
android:startY="18"
|
||||||
|
android:endX="19"
|
||||||
|
android:endY="6"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF6598E3"/>
|
||||||
|
<item android:offset="1" android:color="#FF74A2E6"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M4,4C4,2 6,2 6,4L14,4C14,2 16,2 16,4L19,4L19,6L1,6L1,4Z"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="0"
|
||||||
|
android:strokeLineCap="round">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="1"
|
||||||
|
android:startY="2.5"
|
||||||
|
android:endX="19"
|
||||||
|
android:endY="2.5"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF4260C4"/>
|
||||||
|
<item android:offset="1" android:color="#FF0E2CB0"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
<aapt:attr name="android:strokeColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="1"
|
||||||
|
android:startY="2.5"
|
||||||
|
android:endX="19"
|
||||||
|
android:endY="2.5"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF4260C4"/>
|
||||||
|
<item android:offset="1" android:color="#FF0E2CB0"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M3,8L17,8C18,8 18,9 17,9L3,9C2,9 2,8 3,8M3,11L14,11C15,11 15,12 14,12L3,12C2,12 2,11 3,11M4,14L12,14C13,14 13,15 12,15L5,15C4,15 4,14 5,14M3,14C4,14 4,15 3,15C2,15 2,14 3,14"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="0"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeLineCap="round">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="2.25"
|
||||||
|
android:startY="8"
|
||||||
|
android:endX="17.75"
|
||||||
|
android:endY="8"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF4260C4"/>
|
||||||
|
<item android:offset="1" android:color="#FF0E2CB0"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100px" height="100px" viewBox="-10 -10 20 20">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="accent">
|
||||||
|
<stop offset="0%" stop-color="#4260c4" />
|
||||||
|
<stop offset="100%" stop-color="#0e2cb0" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="bg" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#5581d5" />
|
||||||
|
<stop offset="100%" stop-color="#4063cd" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="bga" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#6598e3" />
|
||||||
|
<stop offset="100%" stop-color="#74a2e6" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g stroke-width="0" stroke-linejoin="round" stroke-linecap="round">
|
||||||
|
<!-- <path stroke="#111" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/> -->
|
||||||
|
|
||||||
|
<path stroke="url(#bg)" fill="url(#bg)" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/>
|
||||||
|
<path stroke="url(#bga)" fill="url(#bga)" d="M 9 8 L -9 8 C 2 8 9 4 9 -4 Z"/>
|
||||||
|
|
||||||
|
<!-- <path stroke="#111" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/> -->
|
||||||
|
|
||||||
|
<g stroke="url(#accent)">
|
||||||
|
<path fill="url(#accent)" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/>
|
||||||
|
|
||||||
|
<path fill="url(#accent)" stroke="none" d="M -7 -2 L 7 -2 C 8 -2 8 -1 7 -1 L -7 -1 C -8 -1 -8 -2 -7 -2 M -7 1 L 4 1 C 5 1 5 2 4 2 L -7 2 C -8 2 -8 1 -7 1 M -6 4 L 2 4 C 3 4 3 5 2 5 L -5 5 C -6 5 -6 4 -5 4 M -7 4 C -6 4 -6 5 -7 5 C -8 5 -8 4 -7 4"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">ВятГУ Расписание</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.VyatsuSchedule" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path
|
||||||
|
name="updates"
|
||||||
|
path="updates/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package agzam.vyatsu
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.ksp) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
|
||||||
|
# AGP 9 built-in Kotlin: allow KSP (Room/Moshi) to register generated source sets
|
||||||
|
android.disallowKotlinSourceSets=false
|
||||||
|
|
||||||
|
systemProp.sun.net.client.defaultConnectTimeout=5000
|
||||||
|
systemProp.sun.net.client.defaultReadTimeout=5000
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
|
||||||
|
toolchainVersion=21
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.1.1"
|
||||||
|
coreKtx = "1.10.1"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.1.5"
|
||||||
|
espressoCore = "3.5.1"
|
||||||
|
lifecycleRuntimeKtx = "2.6.1"
|
||||||
|
activityCompose = "1.8.0"
|
||||||
|
kotlin = "2.2.10"
|
||||||
|
composeBom = "2026.02.01"
|
||||||
|
ksp = "2.2.10-2.0.2"
|
||||||
|
room = "2.8.4"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
jsoup = "1.23.2"
|
||||||
|
moshi = "1.15.2"
|
||||||
|
pdfbox = "2.0.27.0"
|
||||||
|
coroutines = "1.8.1"
|
||||||
|
navigationCompose = "2.8.0"
|
||||||
|
monitor = "1.8.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
# Room
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
|
# Networking
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
|
|
||||||
|
# HTML parsing
|
||||||
|
jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" }
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
|
||||||
|
moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
|
||||||
|
moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
|
||||||
|
|
||||||
|
# PDF
|
||||||
|
pdfbox-android = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox" }
|
||||||
|
androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#Mon Aug 31 18:35:43 MSK 2026
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Готовит содержимое keystore (.jks) для вставки в секрет ANDROID_KEYSTORE
|
||||||
|
# Кодирует файл в base64 одной длинной строкой (без переносов) и сохраняет в файл .keystore.b64 рядом со скриптом.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "Usage: <path>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
KEYSTORE="$1"
|
||||||
|
|
||||||
|
if [ ! -f "$KEYSTORE" ]; then
|
||||||
|
echo "File not found: $KEYSTORE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# base64 -w0: без переносов строк. (на macOS base64 не имеет -w)
|
||||||
|
if base64 --help 2>/dev/null | grep -q '\-w'; then
|
||||||
|
ENCODED=$(base64 -w0 "$KEYSTORE")
|
||||||
|
else
|
||||||
|
ENCODED=$(base64 <"$KEYSTORE" | tr -d '\n')
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
OUTFILE="$SCRIPT_DIR/.keystore.b64"
|
||||||
|
printf '%s\n' "$ENCODED" >"$OUTFILE"
|
||||||
|
|
||||||
|
echo "Keystore: $KEYSTORE"
|
||||||
|
echo "Output: $OUTFILE"
|
||||||
|
echo "Size: ${#ENCODED} chars"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "VyatsuSchedule"
|
||||||
|
include(":app")
|
||||||