Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1195e458a0 | ||
|
|
54d20bd6e0 |
@@ -30,17 +30,9 @@ jobs:
|
|||||||
# Ставим JDK вручную и явно прописываем JAVA_HOME.
|
# Ставим JDK вручную и явно прописываем JAVA_HOME.
|
||||||
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
|
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
apt-get update
|
||||||
cd /tmp
|
apt-get install -y openjdk-17-jdk-headless unzip wget
|
||||||
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
|
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||||
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
|
- name: Install Android SDK
|
||||||
# Устанавливаем command-line tools и нужные компоненты SDK,
|
# Устанавливаем command-line tools и нужные компоненты SDK,
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
# vyatsu-schedule
|
# vyatsu-schedulegit
|
||||||
|
|
||||||
|
## settings-branch
|
||||||
|
blank settings at first, soon will add update feature.
|
||||||
|
|
||||||
|
settings will be presetned as fourth button on the bottom of the screen.
|
||||||
@@ -19,7 +19,7 @@ android {
|
|||||||
versionCode = 1
|
versionCode = 1
|
||||||
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
|
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
|
||||||
// Локально и при отсутствии переменной — fallback "1.0".
|
// Локально и при отсутствии переменной — fallback "1.0".
|
||||||
versionName = System.getenv("APP_VERSION") ?: "debug"
|
versionName = System.getenv("APP_VERSION") ?: "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,6 @@ android {
|
|||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
unitTests.all { it.maxHeapSize = "16m" }
|
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
|||||||
@@ -1,427 +0,0 @@
|
|||||||
{
|
|
||||||
"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')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,13 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".VyatsuApplication"
|
android:name=".VyatsuApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@drawable/icon"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
@@ -26,16 +25,6 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
After Width: | Height: | Size: 234 KiB |
@@ -5,10 +5,6 @@ import agzam.vyatsu.ui.groups.GroupPickerScreen
|
|||||||
import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
||||||
import agzam.vyatsu.ui.home.HomeScreen
|
import agzam.vyatsu.ui.home.HomeScreen
|
||||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
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.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -23,36 +19,11 @@ fun AppRoot() {
|
|||||||
val container = app.container
|
val container = app.container
|
||||||
|
|
||||||
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
||||||
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||||
|
|
||||||
val state by scheduleViewModel.uiState.collectAsState()
|
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) {
|
if (state.noGroup) {
|
||||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
|
||||||
GroupPickerScreen(
|
GroupPickerScreen(
|
||||||
viewModel = groupPickerViewModel,
|
viewModel = groupPickerViewModel,
|
||||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
||||||
@@ -61,7 +32,6 @@ fun AppRoot() {
|
|||||||
}
|
}
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
scheduleViewModel = scheduleViewModel,
|
scheduleViewModel = scheduleViewModel,
|
||||||
updateViewModel = updateViewModel,
|
|
||||||
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -73,4 +43,3 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
|
|||||||
|
|
||||||
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
|
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 groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
||||||
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
val container = (application as VyatsuApplication).container
|
|
||||||
container.appUpdater.autoCheckAndInstall()
|
|
||||||
setContent {
|
setContent {
|
||||||
VyatsuScheduleTheme {
|
VyatsuScheduleTheme {
|
||||||
AppRoot()
|
AppRoot()
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import androidx.room.TypeConverters
|
|||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
GroupEntity::class,
|
GroupEntity::class,
|
||||||
GroupPeriodEntity::class,
|
|
||||||
ScheduleEntity::class,
|
ScheduleEntity::class,
|
||||||
DayEntity::class,
|
DayEntity::class,
|
||||||
LessonEntity::class,
|
LessonEntity::class,
|
||||||
PreferenceEntity::class
|
PreferenceEntity::class
|
||||||
],
|
],
|
||||||
version = 3,
|
version = 2,
|
||||||
exportSchema = true
|
exportSchema = true
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|||||||
@@ -25,13 +25,4 @@ interface GroupDao {
|
|||||||
|
|
||||||
@Query("SELECT MAX(lastSyncedAt) FROM groups")
|
@Query("SELECT MAX(lastSyncedAt) FROM groups")
|
||||||
suspend fun maxSyncedAt(): Long?
|
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>)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,9 @@ data class GroupEntity(
|
|||||||
@PrimaryKey val id: String,
|
@PrimaryKey val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val faculty: String,
|
val faculty: String,
|
||||||
|
val autumnFirst: Long?,
|
||||||
|
val autumnSecond: Long?,
|
||||||
|
val springFirst: Long?,
|
||||||
|
val springSecond: Long?,
|
||||||
val lastSyncedAt: Long
|
val lastSyncedAt: Long
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -27,16 +27,4 @@ interface PreferencesDao {
|
|||||||
|
|
||||||
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
|
||||||
suspend fun setSelectedDayDate(date: String)
|
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package agzam.vyatsu.data.remote
|
|||||||
import agzam.vyatsu.domain.model.DateRange
|
import agzam.vyatsu.domain.model.DateRange
|
||||||
import agzam.vyatsu.domain.model.Group
|
import agzam.vyatsu.domain.model.Group
|
||||||
import agzam.vyatsu.domain.model.Season
|
import agzam.vyatsu.domain.model.Season
|
||||||
import java.io.InputStream
|
import android.util.Log
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
|
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
|
||||||
@@ -13,13 +14,6 @@ object GroupsParser {
|
|||||||
|
|
||||||
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
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(
|
private val shorthands = mapOf(
|
||||||
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
||||||
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
||||||
@@ -39,118 +33,41 @@ object GroupsParser {
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
|
* Извлекает список групп с HTML страницы
|
||||||
* разбирает его на лету, не материализуя весь HTML в память.
|
|
||||||
*
|
|
||||||
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
|
|
||||||
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
|
|
||||||
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
|
|
||||||
*/
|
*/
|
||||||
fun parseStream(input: InputStream): ParsedGroups {
|
fun parseGroups(html: String): List<Group> {
|
||||||
val reader = input.bufferedReader(Charsets.UTF_8)
|
val doc = Jsoup.parse(html)
|
||||||
val buf = CharArray(CHUNK_SIZE)
|
val container = doc.selectFirst("div.column-center_rasp") ?: return emptyList()
|
||||||
val pending = StringBuilder(CHUNK_SIZE * 2)
|
val tables = container.select("table[style~=border: none !important]")
|
||||||
val groups = ArrayList<Group>()
|
|
||||||
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
|
||||||
var faculty = ""
|
|
||||||
|
|
||||||
fun addRanges(text: String) {
|
return tables.flatMap { table ->
|
||||||
for (m in groupPattern.findAll(text)) {
|
table.select("> tbody > tr, > tr").flatMap { row ->
|
||||||
val groupId = m.groupValues[1]
|
Log.v("ROW", row.html());
|
||||||
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
val faculty = shortname(row.selectFirst("div.fak_name")?.text().orEmpty()) //
|
||||||
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
|
||||||
ranges.getOrPut(groupId) {
|
row.select("div.grpPeriod").map { grp ->
|
||||||
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
|
Group(
|
||||||
}.getOrPut(season) { ArrayList() }.add(range)
|
id = grp.attr("data-grp_period_id").dropLast(1),
|
||||||
|
name = removeParenthesized(grp.text()),
|
||||||
|
faculty = faculty
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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-тега — символ '>' вне кавычек. */
|
fun parseDateRanges(html: String): Map<String, Map<Season, List<DateRange>>> {
|
||||||
private fun findTagEnd(s: CharSequence, start: Int): Int {
|
val result = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
||||||
var i = start
|
groupPattern.findAll(html).forEach { m ->
|
||||||
var quote = 0.toChar()
|
val groupId = m.groupValues[1]
|
||||||
while (i < s.length) {
|
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
||||||
val c = s[i]
|
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
||||||
if (quote != 0.toChar()) {
|
result
|
||||||
if (c == quote) quote = 0.toChar()
|
.getOrPut(groupId) { hashMapOf(Season.AUTUMN to mutableListOf(), Season.SPRING to mutableListOf()) }
|
||||||
} else {
|
.getOrPut(season) { mutableListOf() }
|
||||||
when (c) {
|
.add(range)
|
||||||
'"', '\'' -> quote = c
|
|
||||||
'>' -> return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
}
|
||||||
return -1
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shortname(name: String): String {
|
private fun shortname(name: String): String {
|
||||||
@@ -159,10 +76,5 @@ object GroupsParser {
|
|||||||
return if (sh != null) "$sh ($stripped)" else stripped
|
return if (sh != null) "$sh ($stripped)" else stripped
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
|
private fun removeParenthesized(name: String): String = name.replace(Regex("""\(.*?\)\s?"""), "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ParsedGroups(
|
|
||||||
val groups: List<Group>,
|
|
||||||
val dateRanges: Map<String, Map<Season, List<DateRange>>>
|
|
||||||
)
|
|
||||||
@@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.InputStream
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,15 +35,4 @@ object VyatsuHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Открывает потоковое тело ответа. Вызывающий обязан закрыть
|
|
||||||
* возвращённый [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()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -2,18 +2,16 @@ package agzam.vyatsu.data.repository
|
|||||||
|
|
||||||
import agzam.vyatsu.data.local.GroupDao
|
import agzam.vyatsu.data.local.GroupDao
|
||||||
import agzam.vyatsu.data.local.GroupEntity
|
import agzam.vyatsu.data.local.GroupEntity
|
||||||
import agzam.vyatsu.data.local.GroupPeriodEntity
|
|
||||||
import agzam.vyatsu.data.remote.GroupsParser
|
import agzam.vyatsu.data.remote.GroupsParser
|
||||||
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||||
import agzam.vyatsu.data.remote.VyatsuUrls
|
import agzam.vyatsu.data.remote.VyatsuUrls
|
||||||
import agzam.vyatsu.domain.ScheduleDates
|
import agzam.vyatsu.domain.ScheduleDates
|
||||||
import agzam.vyatsu.domain.model.Group
|
import agzam.vyatsu.domain.model.Group
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
import agzam.vyatsu.domain.repository.GroupRepository
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
class GroupRepositoryImpl(
|
class GroupRepositoryImpl(
|
||||||
private val groupDao: GroupDao,
|
private val groupDao: GroupDao,
|
||||||
@@ -42,36 +40,29 @@ class GroupRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refresh() {
|
private suspend fun refresh() {
|
||||||
val parsed = withContext(Dispatchers.IO) {
|
val html = VyatsuHttpClient.getText(VyatsuUrls.GROUPS_PAGE)
|
||||||
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
|
val groups = GroupsParser.parseGroups(html)
|
||||||
|
val ranges = GroupsParser.parseDateRanges(html)
|
||||||
|
val entities = groups.map { group ->
|
||||||
|
val range = ranges[group.id]
|
||||||
|
val autumn = range?.get(Season.AUTUMN)?.lastOrNull()
|
||||||
|
val spring = range?.get(Season.SPRING)?.lastOrNull()
|
||||||
|
GroupEntity(
|
||||||
|
id = group.id,
|
||||||
|
name = group.name,
|
||||||
|
faculty = group.faculty,
|
||||||
|
autumnFirst = autumn?.first?.let(::epochDay),
|
||||||
|
autumnSecond = autumn?.second?.let(::epochDay),
|
||||||
|
springFirst = spring?.first?.let(::epochDay),
|
||||||
|
springSecond = spring?.second?.let(::epochDay),
|
||||||
|
lastSyncedAt = now()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val now = now()
|
groupDao.insertAll(entities)
|
||||||
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 epochDay(dateStr: String): Long = ScheduleDates.asDate(dateStr).toEpochDay()
|
||||||
|
|
||||||
private fun now(): Long = System.currentTimeMillis()
|
private fun now(): Long = System.currentTimeMillis()
|
||||||
|
|
||||||
private fun GroupEntity.toDomain(): Group = Group(
|
private fun GroupEntity.toDomain(): Group = Group(
|
||||||
|
|||||||
@@ -13,10 +13,4 @@ class PreferencesRepositoryImpl(
|
|||||||
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
|
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
|
||||||
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
|
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
|
||||||
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
|
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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package agzam.vyatsu.data.repository
|
package agzam.vyatsu.data.repository
|
||||||
|
|
||||||
import agzam.vyatsu.data.local.DayEntity
|
import agzam.vyatsu.data.local.DayEntity
|
||||||
import agzam.vyatsu.data.local.GroupPeriodEntity
|
|
||||||
import agzam.vyatsu.data.local.LessonEntity
|
import agzam.vyatsu.data.local.LessonEntity
|
||||||
import agzam.vyatsu.data.local.ScheduleDao
|
import agzam.vyatsu.data.local.ScheduleDao
|
||||||
import agzam.vyatsu.data.local.ScheduleEntity
|
import agzam.vyatsu.data.local.ScheduleEntity
|
||||||
@@ -52,38 +51,15 @@ class ScheduleRepositoryImpl(
|
|||||||
Log.w(TAG, "sync: group $groupId not found in DB")
|
Log.w(TAG, "sync: group $groupId not found in DB")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val today = ScheduleDates.moscowToday().toEpochDay()
|
val season = ScheduleDates.currentSeason()
|
||||||
val periods = groupDao.getCurrentAndFuturePeriods(groupId, today)
|
val range = dateRangeFor(group, season) ?: run {
|
||||||
if (periods.isEmpty()) {
|
Log.w(TAG, "sync: no date range for season $season (group ${group.id})")
|
||||||
Log.w(TAG, "sync: no current/future periods for group ${group.id}")
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
|
||||||
|
|
||||||
for (period in periods) {
|
return try {
|
||||||
syncPeriod(group, period)
|
val pdfUrl = VyatsuUrls.groupPdfUrl(groupId, season, range)
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
Log.d(TAG, "sync: downloading $pdfUrl")
|
||||||
val bytes = VyatsuHttpClient.getBytes(pdfUrl)
|
val bytes = VyatsuHttpClient.getBytes(pdfUrl)
|
||||||
Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
|
Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
|
||||||
@@ -91,19 +67,29 @@ class ScheduleRepositoryImpl(
|
|||||||
val days = PdfScheduleParser.parse(bytes)
|
val days = PdfScheduleParser.parse(bytes)
|
||||||
Log.d(TAG, "sync: parsed ${days.size} days")
|
Log.d(TAG, "sync: parsed ${days.size} days")
|
||||||
|
|
||||||
val scheduleId = existing?.id ?: scheduleDao.insertSchedule(
|
val weekStart = ScheduleDates.asDate(range.first).toEpochDay()
|
||||||
|
val weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
|
||||||
|
|
||||||
|
val existing = scheduleDao.getSchedules(groupId)
|
||||||
|
.firstOrNull { it.season == season && it.weekStart == weekStart }
|
||||||
|
val scheduleId = if (existing != null) existing.id
|
||||||
|
else scheduleDao.insertSchedule(
|
||||||
ScheduleEntity(
|
ScheduleEntity(
|
||||||
groupId = group.id,
|
groupId = groupId,
|
||||||
season = season,
|
season = season,
|
||||||
weekStart = period.weekStart,
|
weekStart = weekStart,
|
||||||
weekEnd = period.weekEnd,
|
weekEnd = weekEnd,
|
||||||
lastSyncedAt = now()
|
lastSyncedAt = now()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
replaceDays(scheduleId, days)
|
replaceDays(scheduleId, days)
|
||||||
Log.d(TAG, "sync: period $range synced")
|
|
||||||
|
scheduleDao.trim(groupId, MAX_PERIODS)
|
||||||
|
|
||||||
|
getSchedule(groupId)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e)
|
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,13 +187,30 @@ class ScheduleRepositoryImpl(
|
|||||||
teacher = teacher
|
teacher = teacher
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
|
||||||
|
return when (season) {
|
||||||
|
Season.AUTUMN -> if (group.autumnFirst != null && group.autumnSecond != null) {
|
||||||
|
DateRange(
|
||||||
|
LocalDate.ofEpochDay(group.autumnFirst).format(RANGE_FMT),
|
||||||
|
LocalDate.ofEpochDay(group.autumnSecond).format(RANGE_FMT)
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
Season.SPRING -> if (group.springFirst != null && group.springSecond != null) {
|
||||||
|
DateRange(
|
||||||
|
LocalDate.ofEpochDay(group.springFirst).format(RANGE_FMT),
|
||||||
|
LocalDate.ofEpochDay(group.springSecond).format(RANGE_FMT)
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
|
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
|
||||||
|
|
||||||
private fun now(): Long = System.currentTimeMillis()
|
private fun now(): Long = System.currentTimeMillis()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
||||||
private const val MAX_PERIODS = 24
|
private const val MAX_PERIODS = 3
|
||||||
private const val TAG = "ScheduleRepo"
|
private const val TAG = "ScheduleRepo"
|
||||||
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
|
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import agzam.vyatsu.data.repository.ScheduleRepositoryImpl
|
|||||||
import agzam.vyatsu.domain.repository.GroupRepository
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||||
import agzam.vyatsu.update.AppUpdater
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
|
|
||||||
@@ -38,8 +37,4 @@ class AppContainer(context: Context) {
|
|||||||
val preferencesRepository: PreferencesRepository by lazy {
|
val preferencesRepository: PreferencesRepository by lazy {
|
||||||
PreferencesRepositoryImpl(database.preferencesDao())
|
PreferencesRepositoryImpl(database.preferencesDao())
|
||||||
}
|
}
|
||||||
|
|
||||||
val appUpdater: AppUpdater by lazy {
|
|
||||||
AppUpdater(context.applicationContext, preferencesRepository)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,4 @@ interface PreferencesRepository {
|
|||||||
suspend fun setSelectedWeekStart(weekStart: String)
|
suspend fun setSelectedWeekStart(weekStart: String)
|
||||||
suspend fun selectedDayDate(): String?
|
suspend fun selectedDayDate(): String?
|
||||||
suspend fun setSelectedDayDate(date: String)
|
suspend fun setSelectedDayDate(date: String)
|
||||||
|
|
||||||
suspend fun autoUpdateEnabled(): Boolean
|
|
||||||
suspend fun setAutoUpdateEnabled(enabled: Boolean)
|
|
||||||
|
|
||||||
suspend fun autoUpdateAsked(): Boolean
|
|
||||||
suspend fun setAutoUpdateAsked(asked: Boolean)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import agzam.vyatsu.ui.info.CallsScreen
|
|||||||
import agzam.vyatsu.ui.schedule.ScheduleScreen
|
import agzam.vyatsu.ui.schedule.ScheduleScreen
|
||||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||||
import agzam.vyatsu.ui.settings.SettingsScreen
|
import agzam.vyatsu.ui.settings.SettingsScreen
|
||||||
import agzam.vyatsu.ui.settings.UpdateViewModel
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -35,7 +34,6 @@ private enum class HomeTab(val label: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
scheduleViewModel: ScheduleViewModel,
|
scheduleViewModel: ScheduleViewModel,
|
||||||
updateViewModel: UpdateViewModel,
|
|
||||||
onSwitchGroup: () -> Unit
|
onSwitchGroup: () -> Unit
|
||||||
) {
|
) {
|
||||||
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
||||||
@@ -73,7 +71,7 @@ fun HomeScreen(
|
|||||||
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||||
HomeTab.CALLS -> CallsScreen()
|
HomeTab.CALLS -> CallsScreen()
|
||||||
HomeTab.BUILDINGS -> BuildingsScreen()
|
HomeTab.BUILDINGS -> BuildingsScreen()
|
||||||
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
|
HomeTab.SETTINGS -> SettingsScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,32 +4,23 @@ import agzam.vyatsu.domain.model.Day
|
|||||||
import agzam.vyatsu.domain.model.LessonType
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
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.systemBars
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
@@ -42,7 +33,6 @@ import androidx.compose.material3.FilterChipDefaults
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -56,32 +46,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.compositeOver
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
|
||||||
val hPadding = 16.dp
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||||
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
||||||
actions = {
|
actions = {
|
||||||
if (state.isLoading && state.schedule != null) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextButton(onClick = { onSwitchGroup() }) {
|
TextButton(onClick = { onSwitchGroup() }) {
|
||||||
Text("Сменить группу")
|
Text("Сменить группу")
|
||||||
}
|
}
|
||||||
@@ -91,9 +69,18 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
},
|
},
|
||||||
windowInsets = WindowInsets(0.dp)
|
windowInsets = WindowInsets(0.dp)
|
||||||
)
|
)
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
||||||
when {
|
when {
|
||||||
state.schedule == null && state.loadError -> {
|
state.isSyncing && state.schedule == null -> {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.schedule == null -> {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
@@ -108,15 +95,6 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.schedule == null -> {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val schedule = state.schedule!!
|
val schedule = state.schedule!!
|
||||||
val weekDays = state.weekStart
|
val weekDays = state.weekStart
|
||||||
@@ -132,7 +110,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = hPadding),
|
Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -147,7 +125,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
onSelect = viewModel::selectWeek
|
onSelect = viewModel::selectWeek
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
|
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp))
|
||||||
|
|
||||||
DayChips(
|
DayChips(
|
||||||
days = weekDays,
|
days = weekDays,
|
||||||
@@ -156,14 +134,14 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
onSelect = viewModel::selectDay
|
onSelect = viewModel::selectDay
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
|
HorizontalDivider(Modifier.padding(top=0.dp, bottom = 8.dp))
|
||||||
|
|
||||||
if (day.intervals.all { it.lessons.isEmpty() }) {
|
if (day.intervals.all { it.lessons.isEmpty() }) {
|
||||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(day.intervals) { slotIndex, slot ->
|
itemsIndexed(day.intervals) { slotIndex, slot ->
|
||||||
LessonSlot(slotIndex = slotIndex, slot = slot)
|
LessonSlot(slotIndex = slotIndex, slot = slot)
|
||||||
@@ -249,7 +227,7 @@ private fun DayChips(
|
|||||||
todayDate: LocalDate?,
|
todayDate: LocalDate?,
|
||||||
onSelect: (LocalDate) -> Unit
|
onSelect: (LocalDate) -> Unit
|
||||||
) {
|
) {
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
items(days, key = { it.date.toEpochDay() }) { day ->
|
items(days, key = { it.date.toEpochDay() }) { day ->
|
||||||
val selected = selectedDate == day.date
|
val selected = selectedDate == day.date
|
||||||
val isTodayChip = todayDate == day.date
|
val isTodayChip = todayDate == day.date
|
||||||
@@ -297,97 +275,59 @@ private fun weekLabel(period: SchedulePeriod): String {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||||
val context = LocalContext.current
|
|
||||||
var indexColor = 0
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Text(
|
||||||
HorizontalDivider(
|
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
||||||
modifier = Modifier.width(10.dp + hPadding).padding(start = hPadding),
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
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()) {
|
if (slot.lessons.isEmpty()) {
|
||||||
Text("—", style = MaterialTheme.typography.bodyMedium)
|
Text("—", style = MaterialTheme.typography.bodyMedium)
|
||||||
} else {
|
} else {
|
||||||
slot.lessons.forEachIndexed { index, lesson ->
|
slot.lessons.forEach { lesson ->
|
||||||
var baseSurface = Modifier.fillMaxWidth().padding(vertical = 2.dp)
|
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
if ((indexColor) % 2 != 0) {
|
var any = false
|
||||||
baseSurface = baseSurface.background(
|
if(lesson.type != LessonType.OTHER) {
|
||||||
MaterialTheme.colorScheme.surfaceContainer
|
Text(
|
||||||
);
|
lesson.type.label,
|
||||||
}
|
style = MaterialTheme.typography.bodySmall,
|
||||||
indexColor++
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
// XXX:
|
)
|
||||||
// Убрать SelectionContainer?
|
any = true
|
||||||
// Режим редактирования расписания?
|
|
||||||
// Меню на 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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
|
||||||
|
if(subject.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
subject,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val meta = listOfNotNull(
|
||||||
|
lesson.subgroup,
|
||||||
|
lesson.teacher,
|
||||||
|
lesson.room
|
||||||
|
).filter { it.isNotBlank() }.joinToString(" · ")
|
||||||
|
|
||||||
|
if(meta.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
meta,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!any) {
|
||||||
|
Text(
|
||||||
|
"-",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class ScheduleViewModel(
|
|||||||
|
|
||||||
fun selectGroup(groupId: String) {
|
fun selectGroup(groupId: String) {
|
||||||
currentGroupId = groupId
|
currentGroupId = groupId
|
||||||
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.setSelectedGroup(groupId)
|
preferencesRepository.setSelectedGroup(groupId)
|
||||||
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
||||||
@@ -62,7 +61,7 @@ class ScheduleViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
syncIfMissing(groupId)
|
syncIfMissingToday(groupId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,9 +96,7 @@ class ScheduleViewModel(
|
|||||||
schedule = schedule,
|
schedule = schedule,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
dayDate = dayDate,
|
dayDate = dayDate,
|
||||||
todayDate = todayDate,
|
todayDate = todayDate
|
||||||
isLoading = false,
|
|
||||||
loadError = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,39 +128,30 @@ class ScheduleViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncIfMissing(groupId: String) {
|
private suspend fun syncIfMissingToday(groupId: String) {
|
||||||
if (scheduleRepository.getSchedule(groupId) != null) {
|
val schedule = scheduleRepository.getSchedule(groupId)
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
val today = ScheduleDates.moscowToday()
|
||||||
return
|
val hasTodayPeriod = schedule?.periodOf(today) != null
|
||||||
}
|
if (hasTodayPeriod) return
|
||||||
loadSchedule(groupId)
|
_uiState.value = _uiState.value.copy(isSyncing = true)
|
||||||
}
|
scheduleRepository.sync(groupId)
|
||||||
|
_uiState.value = _uiState.value.copy(isSyncing = false)
|
||||||
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() {
|
fun switchGroup() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
currentGroupId = null
|
currentGroupId = null
|
||||||
preferencesRepository.clearSelectedGroup()
|
preferencesRepository.clearSelectedGroup()
|
||||||
_uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
|
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
val groupId = currentGroupId ?: return
|
val groupId = currentGroupId ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
||||||
// Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
|
scheduleRepository.sync(groupId)
|
||||||
runCatching { groupRepository.forceRefresh() }
|
_uiState.value = _uiState.value.copy(isRefreshing = false)
|
||||||
loadSchedule(groupId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +175,8 @@ class ScheduleViewModel(
|
|||||||
|
|
||||||
data class ScheduleUiState(
|
data class ScheduleUiState(
|
||||||
val noGroup: Boolean = false,
|
val noGroup: Boolean = false,
|
||||||
val isLoading: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val loadError: Boolean = false,
|
val isSyncing: Boolean = false,
|
||||||
val schedule: Schedule? = null,
|
val schedule: Schedule? = null,
|
||||||
val groupName: String? = null,
|
val groupName: String? = null,
|
||||||
val weekStart: LocalDate? = null,
|
val weekStart: LocalDate? = null,
|
||||||
|
|||||||
@@ -1,152 +1,53 @@
|
|||||||
package agzam.vyatsu.ui.settings
|
package agzam.vyatsu.ui.settings
|
||||||
|
|
||||||
import agzam.vyatsu.BuildConfig
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(viewModel: UpdateViewModel) {
|
fun SettingsScreen() {
|
||||||
val autoEnabled by viewModel.autoEnabled.collectAsState()
|
Column(
|
||||||
val updateState by viewModel.updateState.collectAsState()
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.windowInsetsPadding(WindowInsets.statusBars)
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
item { SectionHeader("Обновления") }
|
Spacer(Modifier.height(24.dp))
|
||||||
item {
|
Text(
|
||||||
SettingsItem(
|
"Настройки",
|
||||||
title = "Автоматические обновления",
|
style = MaterialTheme.typography.titleLarge
|
||||||
subtitle = "Проверять и устанавливать новые версии при запуске",
|
)
|
||||||
trailing = {
|
Spacer(Modifier.weight(1f))
|
||||||
Switch(
|
Text(
|
||||||
checked = autoEnabled,
|
"Здесь появятся настройки\n(автоматические обновления и др.)",
|
||||||
onCheckedChange = { viewModel.setAutoEnabled(it) }
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
}
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
SettingsDivider()
|
Spacer(Modifier.weight(1f))
|
||||||
}
|
Text(
|
||||||
item {
|
"Версия ${BuildConfig.VERSION_NAME}",
|
||||||
SettingsItem(
|
style = MaterialTheme.typography.bodySmall,
|
||||||
title = "Проверить наличие обновлений",
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
subtitle = updateStatusText(updateState, autoEnabled),
|
textAlign = TextAlign.Center,
|
||||||
onClick = { viewModel.checkNow() }
|
modifier = Modifier
|
||||||
)
|
.fillMaxWidth()
|
||||||
SettingsDivider()
|
.padding(bottom = 16.dp)
|
||||||
}
|
)
|
||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
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)) {
|
|
||||||
_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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +1,74 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
|
android:width="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<path
|
<path android:fillColor="#3DDC84"
|
||||||
android:fillColor="#3DDC84"
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||||
android:pathData="M9,0L9,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M19,0L19,108"
|
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||||
android:pathData="M29,0L29,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M39,0L39,108"
|
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||||
android:pathData="M49,0L49,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M59,0L59,108"
|
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||||
android:pathData="M69,0L69,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M79,0L79,108"
|
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||||
android:pathData="M89,0L89,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M99,0L99,108"
|
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||||
android:pathData="M0,9L108,9"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M0,19L108,19"
|
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||||
android:pathData="M0,29L108,29"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
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>
|
</vector>
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 24 KiB |
@@ -1,3 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ВятГУ Расписание</string>
|
<string name="app_name">VyatsuSchedule</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths>
|
|
||||||
<cache-path
|
|
||||||
name="updates"
|
|
||||||
path="updates/" />
|
|
||||||
</paths>
|
|
||||||