Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8af24d06e3 | ||
|
|
4e180c8b9b | ||
|
|
014896aaea | ||
|
|
9f6cf3ef30 | ||
|
|
d1923d3296 | ||
|
|
686976347e | ||
|
|
699bcd81f5 | ||
|
|
050f835a33 | ||
|
|
5fee57d93a | ||
|
|
36711d1bf3 | ||
|
|
c2a9cfb7e7 | ||
|
|
467c1a735f | ||
|
|
28965e6aed | ||
|
|
181f8b2f2a | ||
|
|
1fc43bb8fc | ||
|
|
32f93197ab | ||
|
|
32d0cec8ce | ||
|
|
72e63addb2 | ||
|
|
84ae97b906 | ||
|
|
2069ec811a | ||
|
|
ebe1c3c4a3 |
@@ -85,6 +85,9 @@ jobs:
|
|||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
env:
|
env:
|
||||||
|
# Версия приложения — дата, сгенерированная на предыдущем шаге.
|
||||||
|
# Читается в app/build.gradle.kts как versionName (для BuildConfig).
|
||||||
|
APP_VERSION: ${{ steps.version.outputs.version_name }}
|
||||||
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts
|
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts
|
||||||
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
|
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
@@ -112,9 +115,3 @@ jobs:
|
|||||||
files: ${{ steps.apk.outputs.apk_path }}
|
files: ${{ steps.apk.outputs.apk_path }}
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
server_url: ${{ gitea.server_url }}
|
server_url: ${{ gitea.server_url }}
|
||||||
|
|
||||||
- name: Upload APK as artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: vyatsu-schedule-${{ steps.version.outputs.version_name }}
|
|
||||||
path: ${{ steps.apk.outputs.apk_path }}
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -17,7 +17,9 @@ android {
|
|||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
|
||||||
|
// Локально и при отсутствии переменной — fallback "1.0".
|
||||||
|
versionName = System.getenv("APP_VERSION") ?: "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -65,6 +67,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ksp {
|
||||||
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "48411ce0f1f22ef1dccd5852a9e97c5d",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "groups",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `autumnFirst` INTEGER, `autumnSecond` INTEGER, `springFirst` INTEGER, `springSecond` INTEGER, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "faculty",
|
||||||
|
"columnName": "faculty",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "autumnFirst",
|
||||||
|
"columnName": "autumnFirst",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "autumnSecond",
|
||||||
|
"columnName": "autumnSecond",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "springFirst",
|
||||||
|
"columnName": "springFirst",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "springSecond",
|
||||||
|
"columnName": "springSecond",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "schedules",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "groupId",
|
||||||
|
"columnName": "groupId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "season",
|
||||||
|
"columnName": "season",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "weekStart",
|
||||||
|
"columnName": "weekStart",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "weekEnd",
|
||||||
|
"columnName": "weekEnd",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_schedules_groupId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"groupId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_schedules_groupId_season_weekStart",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"groupId",
|
||||||
|
"season",
|
||||||
|
"weekStart"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "groups",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"groupId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "days",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "scheduleId",
|
||||||
|
"columnName": "scheduleId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"columnName": "date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_days_scheduleId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"scheduleId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_days_date",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "schedules",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"scheduleId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "lessons",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dayId",
|
||||||
|
"columnName": "dayId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"columnName": "date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "slotIndex",
|
||||||
|
"columnName": "slotIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "start",
|
||||||
|
"columnName": "start",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "end",
|
||||||
|
"columnName": "end",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lessonIndex",
|
||||||
|
"columnName": "lessonIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "raw",
|
||||||
|
"columnName": "raw",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "room",
|
||||||
|
"columnName": "room",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "groupName",
|
||||||
|
"columnName": "groupName",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subgroup",
|
||||||
|
"columnName": "subgroup",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subject",
|
||||||
|
"columnName": "subject",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "teacher",
|
||||||
|
"columnName": "teacher",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_lessons_dayId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"dayId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_lessons_date",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_lessons_subject",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"subject"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_lessons_room",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"room"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "days",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"dayId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "preferences",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "key",
|
||||||
|
"columnName": "key",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "value",
|
||||||
|
"columnName": "value",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '48411ce0f1f22ef1dccd5852a9e97c5d')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
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"
|
||||||
@@ -25,6 +26,16 @@
|
|||||||
<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,6 +5,7 @@ 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.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
|
||||||
@@ -20,6 +21,7 @@ fun AppRoot() {
|
|||||||
|
|
||||||
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
||||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||||
|
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
||||||
|
|
||||||
val state by scheduleViewModel.uiState.collectAsState()
|
val state by scheduleViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ fun AppRoot() {
|
|||||||
}
|
}
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
scheduleViewModel = scheduleViewModel,
|
scheduleViewModel = scheduleViewModel,
|
||||||
|
updateViewModel = updateViewModel,
|
||||||
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -41,5 +44,6 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
|
|||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository) }
|
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,6 +10,8 @@ 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()
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ package agzam.vyatsu.data.local
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
GroupEntity::class,
|
GroupEntity::class,
|
||||||
ScheduleEntity::class,
|
ScheduleEntity::class,
|
||||||
|
DayEntity::class,
|
||||||
|
LessonEntity::class,
|
||||||
PreferenceEntity::class
|
PreferenceEntity::class
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false
|
exportSchema = true
|
||||||
)
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun groupDao(): GroupDao
|
abstract fun groupDao(): GroupDao
|
||||||
abstract fun scheduleDao(): ScheduleDao
|
abstract fun scheduleDao(): ScheduleDao
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
class Converters {
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun localDateToEpochDay(date: LocalDate?): Long? = date?.toEpochDay()
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun epochDayToLocalDate(epochDay: Long?): LocalDate? = epochDay?.let(LocalDate::ofEpochDay)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun localTimeToMinutes(time: LocalTime?): Int? = time?.let { it.hour * 60 + it.minute }
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun minutesToLocalTime(minutes: Int?): LocalTime? =
|
||||||
|
minutes?.let { LocalTime.of(it / 60, it % 60) }
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun lessonTypeToString(type: LessonType): String = type.name
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun stringToLessonType(value: String): LessonType =
|
||||||
|
runCatching { LessonType.valueOf(value) }.getOrDefault(LessonType.OTHER)
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Day
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class DayDto(
|
|
||||||
val date: String,
|
|
||||||
val slots: List<SlotDto> = emptyList()
|
|
||||||
) {
|
|
||||||
fun toDomain(): Day = Day(
|
|
||||||
date = LocalDate.parse(date),
|
|
||||||
intervals = slots.map { it.toDomain() }
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromDomain(d: Day): DayDto = DayDto(
|
|
||||||
date = d.date.toString(),
|
|
||||||
slots = d.intervals.map { SlotDto.fromDomain(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "days",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = ScheduleEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["scheduleId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index("scheduleId"),
|
||||||
|
Index("date")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class DayEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val scheduleId: Long,
|
||||||
|
val date: Long
|
||||||
|
)
|
||||||
@@ -8,9 +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: String,
|
val autumnFirst: Long?,
|
||||||
val autumnSecond: String,
|
val autumnSecond: Long?,
|
||||||
val springFirst: String,
|
val springFirst: Long?,
|
||||||
val springSecond: String,
|
val springSecond: Long?,
|
||||||
val lastSyncedAt: Long
|
val lastSyncedAt: Long
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Lesson
|
|
||||||
import agzam.vyatsu.domain.model.LessonType
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class LessonDto(
|
|
||||||
val raw: String = "",
|
|
||||||
val room: String? = null,
|
|
||||||
val group: String? = null,
|
|
||||||
val subgroup: String? = null,
|
|
||||||
val subject: String? = null,
|
|
||||||
val type: String = "",
|
|
||||||
val teacher: String? = null
|
|
||||||
) {
|
|
||||||
fun toDomain(): Lesson = Lesson(
|
|
||||||
raw = raw,
|
|
||||||
room = room?.takeIf { it.isNotBlank() },
|
|
||||||
group = group?.takeIf { it.isNotBlank() },
|
|
||||||
subgroup = subgroup?.takeIf { it.isNotBlank() },
|
|
||||||
subject = subject?.takeIf { it.isNotBlank() },
|
|
||||||
type = runCatching { LessonType.valueOf(type) }.getOrDefault(LessonType.OTHER),
|
|
||||||
teacher = teacher?.takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromDomain(l: Lesson): LessonDto = LessonDto(
|
|
||||||
raw = l.raw,
|
|
||||||
room = l.room,
|
|
||||||
group = l.group,
|
|
||||||
subgroup = l.subgroup,
|
|
||||||
subject = l.subject,
|
|
||||||
type = l.type.name,
|
|
||||||
teacher = l.teacher
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "lessons",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = DayEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["dayId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index("dayId"),
|
||||||
|
Index("date"),
|
||||||
|
Index("subject"),
|
||||||
|
Index("room")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class LessonEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val dayId: Long,
|
||||||
|
val date: Long,
|
||||||
|
val slotIndex: Int,
|
||||||
|
val start: Int,
|
||||||
|
val end: Int,
|
||||||
|
val lessonIndex: Int,
|
||||||
|
val raw: String,
|
||||||
|
val room: String?,
|
||||||
|
val groupName: String?,
|
||||||
|
val subgroup: String?,
|
||||||
|
val subject: String?,
|
||||||
|
val type: LessonType,
|
||||||
|
val teacher: String?
|
||||||
|
)
|
||||||
@@ -15,4 +15,22 @@ interface PreferencesDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
|
@Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
|
||||||
suspend fun clearSelectedGroup()
|
suspend fun clearSelectedGroup()
|
||||||
|
|
||||||
|
@Query("SELECT value FROM preferences WHERE key = 'selectedWeekStart'")
|
||||||
|
suspend fun selectedWeekStart(): String?
|
||||||
|
|
||||||
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedWeekStart', :weekStart)")
|
||||||
|
suspend fun setSelectedWeekStart(weekStart: String)
|
||||||
|
|
||||||
|
@Query("SELECT value FROM preferences WHERE key = 'selectedDayDate'")
|
||||||
|
suspend fun selectedDayDate(): String?
|
||||||
|
|
||||||
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
|
||||||
|
suspend fun setSelectedDayDate(date: String)
|
||||||
|
|
||||||
|
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateEnabled'")
|
||||||
|
suspend fun autoUpdateEnabled(): String?
|
||||||
|
|
||||||
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateEnabled', :enabled)")
|
||||||
|
suspend fun setAutoUpdateEnabled(enabled: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Day
|
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import com.squareup.moshi.Types
|
|
||||||
|
|
||||||
class ScheduleCodec(moshi: Moshi) {
|
|
||||||
private val type: java.lang.reflect.Type =
|
|
||||||
Types.newParameterizedType(List::class.java, DayDto::class.java)
|
|
||||||
private val adapter = moshi.adapter<List<DayDto>>(type)
|
|
||||||
|
|
||||||
fun toJson(days: List<Day>): String {
|
|
||||||
val dto = days.map { DayDto.fromDomain(it) }
|
|
||||||
return adapter.toJson(dto)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fromJson(json: String): List<Day> {
|
|
||||||
return try {
|
|
||||||
val dto = adapter.fromJson(json) ?: return emptyList()
|
|
||||||
dto.map { it.toDomain() }
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,68 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ScheduleDao {
|
interface ScheduleDao {
|
||||||
@Query("SELECT * FROM schedules WHERE groupId = :groupId")
|
|
||||||
fun observe(groupId: String): Flow<ScheduleEntity?>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM schedules WHERE groupId = :groupId")
|
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
|
||||||
suspend fun get(groupId: String): ScheduleEntity?
|
fun observeSchedules(groupId: String): Flow<List<ScheduleEntity>>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
|
||||||
suspend fun upsert(entity: ScheduleEntity)
|
suspend fun getSchedules(groupId: String): List<ScheduleEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM days WHERE scheduleId = :scheduleId")
|
||||||
|
suspend fun deleteDaysForSchedule(scheduleId: Long)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
|
suspend fun insertSchedule(schedule: ScheduleEntity): Long
|
||||||
|
|
||||||
|
@Query("DELETE FROM schedules WHERE groupId = :groupId AND season = :season AND weekStart = :weekStart")
|
||||||
|
suspend fun deleteSchedule(groupId: String, season: String, weekStart: Long)
|
||||||
|
|
||||||
@Query("DELETE FROM schedules WHERE groupId = :groupId")
|
@Query("DELETE FROM schedules WHERE groupId = :groupId")
|
||||||
suspend fun delete(groupId: String)
|
suspend fun deleteAll(groupId: String)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
DELETE FROM schedules
|
||||||
|
WHERE groupId = :groupId
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id FROM schedules
|
||||||
|
WHERE groupId = :groupId
|
||||||
|
ORDER BY weekStart DESC
|
||||||
|
LIMIT :keep
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun trim(groupId: String, keep: Int)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertDays(days: List<DayEntity>): List<Long>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertLessons(lessons: List<LessonEntity>)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
|
||||||
|
"WHERE schedules.groupId = :groupId ORDER BY days.date"
|
||||||
|
)
|
||||||
|
suspend fun getDaysForGroup(groupId: String): List<DayEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
|
||||||
|
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
|
||||||
|
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
|
||||||
|
)
|
||||||
|
suspend fun getLessonsForGroup(groupId: String): List<LessonEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
|
||||||
|
"WHERE schedules.groupId = :groupId ORDER BY days.date"
|
||||||
|
)
|
||||||
|
fun observeDaysForGroup(groupId: String): Flow<List<DayEntity>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
|
||||||
|
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
|
||||||
|
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
|
||||||
|
)
|
||||||
|
fun observeLessonsForGroup(groupId: String): Flow<List<LessonEntity>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
package agzam.vyatsu.data.local
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(tableName = "schedules")
|
@Entity(
|
||||||
|
tableName = "schedules",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = GroupEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["groupId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index("groupId"),
|
||||||
|
Index(value = ["groupId", "season", "weekStart"], unique = true)
|
||||||
|
]
|
||||||
|
)
|
||||||
data class ScheduleEntity(
|
data class ScheduleEntity(
|
||||||
@PrimaryKey val groupId: String,
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
val season: String,
|
val groupId: String,
|
||||||
val dateRangeFirst: String,
|
val season: Season,
|
||||||
val dateRangeSecond: String,
|
val weekStart: Long,
|
||||||
val weeksJson: String,
|
val weekEnd: Long,
|
||||||
val lastSyncedAt: Long
|
val lastSyncedAt: Long
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class SlotDto(
|
|
||||||
val start: String,
|
|
||||||
val end: String,
|
|
||||||
val lessons: List<LessonDto> = emptyList()
|
|
||||||
) {
|
|
||||||
fun toDomain(): TimeSlot = TimeSlot(
|
|
||||||
start = LocalTime.parse(start),
|
|
||||||
end = LocalTime.parse(end),
|
|
||||||
lessons = lessons.map { it.toDomain() }
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromDomain(s: TimeSlot): SlotDto = SlotDto(
|
|
||||||
start = s.start.toString(),
|
|
||||||
end = s.end.toString(),
|
|
||||||
lessons = s.lessons.map { LessonDto.fromDomain(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
|||||||
|
|
||||||
private val svgBuilder = StringBuilder()
|
private val svgBuilder = StringBuilder()
|
||||||
private val currentPath = StringBuilder()
|
private val currentPath = StringBuilder()
|
||||||
private val tableSvg = StringBuilder()
|
val tableSvg = StringBuilder()
|
||||||
|
|
||||||
|
|
||||||
private val currentSubPathLines = mutableListOf<PdfLine>()
|
private val currentSubPathLines = mutableListOf<PdfLine>()
|
||||||
@@ -52,7 +52,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
|||||||
val width = mediaBox.width
|
val width = mediaBox.width
|
||||||
val height = mediaBox.height
|
val height = mediaBox.height
|
||||||
return """
|
return """
|
||||||
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style='font-size: 50%;'>
|
||||||
<g>${svgBuilder}</g>
|
<g>${svgBuilder}</g>
|
||||||
<g>${tableSvg}</g>
|
<g>${tableSvg}</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -211,7 +211,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
|||||||
val bottom = ys[ii+1]
|
val bottom = ys[ii+1]
|
||||||
cells.add(TableCell(left, top, right-left, bottom-top))
|
cells.add(TableCell(left, top, right-left, bottom-top))
|
||||||
|
|
||||||
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f" else "#0"}${if (ii % 2 == 0) "f0" else "00"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
|
tableSvg.append("<rect fill='${if (i % 2 == 0) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
|
||||||
}
|
}
|
||||||
columns.add(TableColumn(left, right-left, cells))
|
columns.add(TableColumn(left, right-left, cells))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package agzam.vyatsu.data.remote.pdf
|
|||||||
|
|
||||||
import agzam.vyatsu.domain.model.Day
|
import agzam.vyatsu.domain.model.Day
|
||||||
import agzam.vyatsu.domain.model.Lesson
|
import agzam.vyatsu.domain.model.Lesson
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||||
@@ -25,6 +26,10 @@ object PdfScheduleParser {
|
|||||||
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
|
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
|
||||||
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
|
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
|
||||||
|
|
||||||
|
private val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
|
||||||
|
private val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" } )
|
||||||
|
private val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
fun parse(pdfBytes: ByteArray): List<Day> {
|
fun parse(pdfBytes: ByteArray): List<Day> {
|
||||||
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
||||||
val doc = try {
|
val doc = try {
|
||||||
@@ -35,6 +40,8 @@ object PdfScheduleParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val days = mutableListOf<Day>()
|
val days = mutableListOf<Day>()
|
||||||
|
var group: String? = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (pageIndex in 0 until doc.numberOfPages) {
|
for (pageIndex in 0 until doc.numberOfPages) {
|
||||||
Log.v(TAG, "============ Page $pageIndex ============")
|
Log.v(TAG, "============ Page $pageIndex ============")
|
||||||
@@ -45,12 +52,6 @@ object PdfScheduleParser {
|
|||||||
svg.processPage(page)
|
svg.processPage(page)
|
||||||
val cols = svg.asTable()
|
val cols = svg.asTable()
|
||||||
|
|
||||||
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
|
||||||
Log.v(SVG_TAG, "====")
|
|
||||||
for(line in svg.asSvg().split("\n")) {
|
|
||||||
Log.v(SVG_TAG, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
|
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
|
||||||
|
|
||||||
// Парсинг текста
|
// Парсинг текста
|
||||||
@@ -60,6 +61,11 @@ object PdfScheduleParser {
|
|||||||
textStripper.getText(doc)
|
textStripper.getText(doc)
|
||||||
val texts = textStripper.blocks
|
val texts = textStripper.blocks
|
||||||
|
|
||||||
|
for(text in texts) {
|
||||||
|
svg.tableSvg.appendLine("<text x='${text.x}' y='${text.y}'>${text.text}</text>")
|
||||||
|
svg.tableSvg.appendLine("<circle cx='${text.x}' cy='${text.y}' r='1' fill='red'/>")
|
||||||
|
}
|
||||||
|
|
||||||
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
|
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
|
||||||
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
|
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
|
||||||
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
|
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
|
||||||
@@ -100,10 +106,17 @@ object PdfScheduleParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
for (dayCell in daysColumn.cells) {
|
for (dayCell in daysColumn.cells) {
|
||||||
val day = content(dayCell).split(' ')
|
val day = content(dayCell).split(' ')
|
||||||
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
if(day.size == 1 && day.first() == "День") {
|
||||||
|
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
|
||||||
|
group = content(groupCell)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if(day.size != 2) {
|
||||||
|
Log.w(TAG, "Day data is not in format <day> <date>: $day")
|
||||||
|
continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
||||||
|
}
|
||||||
|
|
||||||
val date = dateFrom(day[1]) ?: continue
|
val date = dateFrom(day[1]) ?: continue
|
||||||
val intervals = mutableListOf<TimeSlot>()
|
val intervals = mutableListOf<TimeSlot>()
|
||||||
@@ -124,7 +137,55 @@ object PdfScheduleParser {
|
|||||||
val content = content(lessonCell)
|
val content = content(lessonCell)
|
||||||
Log.v(TAG, "- $content")
|
Log.v(TAG, "- $content")
|
||||||
// TODO: парсинг контента лекции
|
// TODO: парсинг контента лекции
|
||||||
lessons.add(Lesson(raw=content))
|
|
||||||
|
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
|
||||||
|
var found = 0
|
||||||
|
var currentStart = 0
|
||||||
|
for (roomMatch in ROOM_REGEX.findAll(content)) {
|
||||||
|
found++
|
||||||
|
|
||||||
|
val raw = content.substring(currentStart, roomMatch.range.last + 1)
|
||||||
|
currentStart = roomMatch.range.last + 1
|
||||||
|
|
||||||
|
// Предполагаемый формат: [группа], [XX подгруппа] <предмет> <тип> [id + тип] <ФИО> <кабинет>
|
||||||
|
|
||||||
|
val room = roomMatch.value
|
||||||
|
|
||||||
|
val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList()
|
||||||
|
if(typeMatch.isEmpty()) {
|
||||||
|
lessons.add(Lesson(
|
||||||
|
raw=raw,
|
||||||
|
room=room,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var subject = raw.substring(0, typeMatch.first().range.first).trim() // TODO: сейчас [группа], [XX подгруппа] <предмет> - можно достать подгруппу
|
||||||
|
val type = LessonType.fromRaw(typeMatch.first().value.trim())
|
||||||
|
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
|
||||||
|
var subgroup: String? = null
|
||||||
|
Log.d(TAG, "$subject $group")
|
||||||
|
if(group != null && subject.startsWith("$group,")) {
|
||||||
|
subject = subject.substring(group.length + 1).trim()
|
||||||
|
val subgroupMatch = SUBGROUP_REGEX.find(subject)
|
||||||
|
if(subgroupMatch != null) {
|
||||||
|
subgroup = subgroupMatch.value.trim()
|
||||||
|
subject = subject.removeRange(subgroupMatch.range).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
lessons.add(Lesson(
|
||||||
|
raw=raw,
|
||||||
|
subject=subject,
|
||||||
|
room=room,
|
||||||
|
type=type,
|
||||||
|
teacher=teacher,
|
||||||
|
subgroup=subgroup
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if(found == 0) lessons.add(Lesson(raw=content))
|
||||||
|
|
||||||
}
|
}
|
||||||
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
|
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
|
||||||
}
|
}
|
||||||
@@ -133,6 +194,18 @@ object PdfScheduleParser {
|
|||||||
for (text in texts.filter { it.rotation != 0f }) {
|
for (text in texts.filter { it.rotation != 0f }) {
|
||||||
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
|
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
||||||
|
Log.v(SVG_TAG, "====")
|
||||||
|
for(line in svg.asSvg().split("\n")) {
|
||||||
|
if(line.length > 250) {
|
||||||
|
Log.v(SVG_TAG, "length: " + line.length)
|
||||||
|
Log.v(SVG_TAG + "-" + (pageIndex+1), "length: " + line.length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Log.v(SVG_TAG, line)
|
||||||
|
Log.v(SVG_TAG + "-" + (pageIndex+1), line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Parsing error", e)
|
Log.e(TAG, "Parsing error", e)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.tom_roush.pdfbox.text.TextPosition
|
|||||||
* Парсер блоков текста из PDF
|
* Парсер блоков текста из PDF
|
||||||
* @author Agzam
|
* @author Agzam
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class PdfTextParser : PDFTextStripper() {
|
class PdfTextParser : PDFTextStripper() {
|
||||||
|
|
||||||
data class TextBlock(
|
data class TextBlock(
|
||||||
@@ -25,21 +26,51 @@ class PdfTextParser : PDFTextStripper() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
|
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
|
||||||
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) {
|
if (textPositions.isNullOrEmpty()) return
|
||||||
val first = textPositions.first()
|
|
||||||
val last = textPositions.last()
|
|
||||||
|
|
||||||
val matrix = first.textMatrix
|
val currentWordChars = mutableListOf<TextPosition>()
|
||||||
val rotation = first.dir
|
|
||||||
|
|
||||||
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
|
for (tp in textPositions) {
|
||||||
|
val unicode = tp.unicode
|
||||||
|
|
||||||
val x = matrix.translateX
|
if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
|
||||||
val y = matrix.translateY - first.heightDir
|
addWordBlock(currentWordChars)
|
||||||
|
currentWordChars.clear()
|
||||||
val height = first.heightDir
|
} else {
|
||||||
|
currentWordChars.add(tp)
|
||||||
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addWordBlock(currentWordChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addWordBlock(wordChars: List<TextPosition>) {
|
||||||
|
if (wordChars.isEmpty()) return
|
||||||
|
|
||||||
|
val first = wordChars.first()
|
||||||
|
val last = wordChars.last()
|
||||||
|
|
||||||
|
val wordText = wordChars.joinToString("") { it.unicode }.trim()
|
||||||
|
if (wordText.isEmpty()) return
|
||||||
|
|
||||||
|
val matrix = first.textMatrix
|
||||||
|
val rotation = first.dir
|
||||||
|
|
||||||
|
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
|
||||||
|
|
||||||
|
val x = matrix.translateX
|
||||||
|
val y = matrix.translateY - first.heightDir
|
||||||
|
val height = first.heightDir
|
||||||
|
|
||||||
|
blocks.add(
|
||||||
|
TextBlock(
|
||||||
|
text = wordText,
|
||||||
|
x = x,
|
||||||
|
y = currentPage.mediaBox.height - y - height,
|
||||||
|
width = totalWidth,
|
||||||
|
height = height,
|
||||||
|
rotation = rotation
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package agzam.vyatsu.data.remote.update
|
||||||
|
|
||||||
|
import com.squareup.moshi.Json
|
||||||
|
import com.squareup.moshi.JsonClass
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class GiteaRelease(
|
||||||
|
@Json(name = "tag_name") val tagName: String?,
|
||||||
|
val name: String?,
|
||||||
|
val body: String?,
|
||||||
|
val assets: List<GiteaAsset> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class GiteaAsset(
|
||||||
|
val name: String?,
|
||||||
|
@Json(name = "browser_download_url") val browserDownloadUrl: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
class GiteaReleaseApi(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
private val baseUrl: String = "https://vantablacktea.ddns.net",
|
||||||
|
private val repoPath: String = "agzam/vyatsu-schedule"
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val moshi = Moshi.Builder().build()
|
||||||
|
private val releaseAdapter = moshi.adapter(GiteaRelease::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает последний релиз или null, если релизов ещё нет (404).
|
||||||
|
*/
|
||||||
|
suspend fun latestRelease(): GiteaRelease? {
|
||||||
|
val url = "$baseUrl/api/v1/repos/$repoPath/releases/latest"
|
||||||
|
val request = Request.Builder().url(url).get().build()
|
||||||
|
|
||||||
|
return client.newCall(request).execute().use { resp ->
|
||||||
|
when {
|
||||||
|
resp.code == 404 -> null
|
||||||
|
!resp.isSuccessful -> throw IOException("HTTP ${resp.code} при запросе релиза")
|
||||||
|
else -> {
|
||||||
|
val body = resp.body ?: throw IOException("Пустой ответ сервера")
|
||||||
|
releaseAdapter.fromJson(body.source())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apkDownloadUrl(release: GiteaRelease): String? =
|
||||||
|
release.assets
|
||||||
|
.firstOrNull { it.name?.endsWith(".apk", ignoreCase = true) == true }
|
||||||
|
?.browserDownloadUrl
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import agzam.vyatsu.data.local.GroupEntity
|
|||||||
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.model.Group
|
import agzam.vyatsu.domain.model.Group
|
||||||
import agzam.vyatsu.domain.model.Season
|
import agzam.vyatsu.domain.model.Season
|
||||||
import agzam.vyatsu.domain.repository.GroupRepository
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
@@ -50,16 +51,18 @@ class GroupRepositoryImpl(
|
|||||||
id = group.id,
|
id = group.id,
|
||||||
name = group.name,
|
name = group.name,
|
||||||
faculty = group.faculty,
|
faculty = group.faculty,
|
||||||
autumnFirst = autumn?.first ?: "",
|
autumnFirst = autumn?.first?.let(::epochDay),
|
||||||
autumnSecond = autumn?.second ?: "",
|
autumnSecond = autumn?.second?.let(::epochDay),
|
||||||
springFirst = spring?.first ?: "",
|
springFirst = spring?.first?.let(::epochDay),
|
||||||
springSecond = spring?.second ?: "",
|
springSecond = spring?.second?.let(::epochDay),
|
||||||
lastSyncedAt = now()
|
lastSyncedAt = now()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
groupDao.insertAll(entities)
|
groupDao.insertAll(entities)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
|||||||
@@ -9,4 +9,11 @@ class PreferencesRepositoryImpl(
|
|||||||
override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
|
override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
|
||||||
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
|
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
|
||||||
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup()
|
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup()
|
||||||
|
override suspend fun selectedWeekStart(): String? = dao.selectedWeekStart()
|
||||||
|
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
|
||||||
|
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
|
||||||
|
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
|
||||||
|
|
||||||
|
override suspend fun autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() != "false"
|
||||||
|
override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package agzam.vyatsu.data.repository
|
package agzam.vyatsu.data.repository
|
||||||
|
|
||||||
import agzam.vyatsu.data.local.ScheduleCodec
|
import agzam.vyatsu.data.local.DayEntity
|
||||||
|
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
|
||||||
import agzam.vyatsu.data.local.GroupDao
|
import agzam.vyatsu.data.local.GroupDao
|
||||||
@@ -9,39 +10,50 @@ import agzam.vyatsu.data.remote.pdf.PdfScheduleParser
|
|||||||
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.Day
|
||||||
import agzam.vyatsu.domain.model.DateRange
|
import agzam.vyatsu.domain.model.DateRange
|
||||||
|
import agzam.vyatsu.domain.model.Lesson
|
||||||
import agzam.vyatsu.domain.model.Schedule
|
import agzam.vyatsu.domain.model.Schedule
|
||||||
|
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||||
import agzam.vyatsu.domain.model.Season
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
class ScheduleRepositoryImpl(
|
class ScheduleRepositoryImpl(
|
||||||
private val scheduleDao: ScheduleDao,
|
private val scheduleDao: ScheduleDao,
|
||||||
private val groupDao: GroupDao,
|
private val groupDao: GroupDao
|
||||||
private val codec: ScheduleCodec
|
|
||||||
) : ScheduleRepository {
|
) : ScheduleRepository {
|
||||||
|
|
||||||
override fun observeSchedule(groupId: String): Flow<Schedule?> =
|
override fun observeSchedule(groupId: String): Flow<Schedule?> =
|
||||||
scheduleDao.observe(groupId).map { it?.toDomain() }
|
combine(
|
||||||
|
scheduleDao.observeSchedules(groupId),
|
||||||
|
scheduleDao.observeDaysForGroup(groupId),
|
||||||
|
scheduleDao.observeLessonsForGroup(groupId)
|
||||||
|
) { schedules, days, lessons ->
|
||||||
|
buildSchedule(schedules, days, lessons)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getSchedule(groupId: String): Schedule? =
|
override suspend fun getSchedule(groupId: String): Schedule? =
|
||||||
scheduleDao.get(groupId)?.toDomain()
|
buildSchedule(
|
||||||
|
scheduleDao.getSchedules(groupId),
|
||||||
|
scheduleDao.getDaysForGroup(groupId),
|
||||||
|
scheduleDao.getLessonsForGroup(groupId)
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun sync(groupId: String): Schedule? {
|
override suspend fun sync(groupId: String): Schedule? {
|
||||||
Log.d(TAG, "sync(groupId=$groupId) start")
|
Log.d(TAG, "sync(groupId=$groupId) start")
|
||||||
val group = groupDao.getById(groupId)
|
val group = groupDao.getById(groupId) ?: run {
|
||||||
if (group == null) {
|
|
||||||
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 season = ScheduleDates.currentSeason()
|
val season = ScheduleDates.currentSeason()
|
||||||
val range = dateRangeFor(group, season)
|
val range = dateRangeFor(group, season) ?: run {
|
||||||
if (range == null) {
|
Log.w(TAG, "sync: no date range for season $season (group ${group.id})")
|
||||||
Log.w(TAG, "sync: no date range for season $season (group ${group.id}, " +
|
|
||||||
"autumn=[${group.autumnFirst}, ${group.autumnSecond}], " +
|
|
||||||
"spring=[${group.springFirst}, ${group.springSecond}])")
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
|
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
|
||||||
@@ -55,17 +67,26 @@ 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 entity = ScheduleEntity(
|
val weekStart = ScheduleDates.asDate(range.first).toEpochDay()
|
||||||
groupId = groupId,
|
val weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
|
||||||
season = season.name,
|
|
||||||
dateRangeFirst = range.first,
|
val existing = scheduleDao.getSchedules(groupId)
|
||||||
dateRangeSecond = range.second,
|
.firstOrNull { it.season == season && it.weekStart == weekStart }
|
||||||
weeksJson = codec.toJson(days),
|
val scheduleId = if (existing != null) existing.id
|
||||||
lastSyncedAt = now()
|
else scheduleDao.insertSchedule(
|
||||||
|
ScheduleEntity(
|
||||||
|
groupId = groupId,
|
||||||
|
season = season,
|
||||||
|
weekStart = weekStart,
|
||||||
|
weekEnd = weekEnd,
|
||||||
|
lastSyncedAt = now()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
scheduleDao.upsert(entity)
|
replaceDays(scheduleId, days)
|
||||||
Log.d(TAG, "sync: upserted schedule for $groupId")
|
|
||||||
entity.toDomain()
|
scheduleDao.trim(groupId, MAX_PERIODS)
|
||||||
|
|
||||||
|
getSchedule(groupId)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
|
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
|
||||||
null
|
null
|
||||||
@@ -73,33 +94,124 @@ class ScheduleRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun isStale(groupId: String): Boolean {
|
override suspend fun isStale(groupId: String): Boolean {
|
||||||
val entity = scheduleDao.get(groupId) ?: return true
|
val schedules = scheduleDao.getSchedules(groupId)
|
||||||
return now() - entity.lastSyncedAt > STALE_WINDOW_MS
|
val latest = schedules.maxByOrNull { it.weekStart } ?: return true
|
||||||
|
return now() - latest.lastSyncedAt > STALE_WINDOW_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun clear(groupId: String) = scheduleDao.delete(groupId)
|
override suspend fun clearPeriod(groupId: String, period: SchedulePeriod) {
|
||||||
|
scheduleDao.deleteSchedule(
|
||||||
|
groupId = groupId,
|
||||||
|
season = period.season.name,
|
||||||
|
weekStart = period.weekStart.toEpochDay()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearAll(groupId: String) = scheduleDao.deleteAll(groupId)
|
||||||
|
|
||||||
|
private suspend fun replaceDays(scheduleId: Long, days: List<Day>) {
|
||||||
|
scheduleDao.deleteDaysForSchedule(scheduleId)
|
||||||
|
val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) }
|
||||||
|
val dayIds = scheduleDao.insertDays(dayEntities)
|
||||||
|
val dayIdByDate = dayEntities.zip(dayIds).associate { (day, id) -> LocalDate.ofEpochDay(day.date) to id }
|
||||||
|
val lessons = days.flatMap { day ->
|
||||||
|
day.intervals.flatMapIndexed { slotIndex, slot ->
|
||||||
|
slot.lessons.mapIndexed { lessonIndex, lesson ->
|
||||||
|
LessonEntity(
|
||||||
|
dayId = dayIdByDate.getValue(day.date),
|
||||||
|
date = day.date.toEpochDay(),
|
||||||
|
slotIndex = slotIndex,
|
||||||
|
start = slot.start.toMinutesOfDay(),
|
||||||
|
end = slot.end.toMinutesOfDay(),
|
||||||
|
lessonIndex = lessonIndex,
|
||||||
|
raw = lesson.raw,
|
||||||
|
room = lesson.room,
|
||||||
|
groupName = lesson.group,
|
||||||
|
subgroup = lesson.subgroup,
|
||||||
|
subject = lesson.subject,
|
||||||
|
type = lesson.type,
|
||||||
|
teacher = lesson.teacher
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleDao.insertLessons(lessons)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSchedule(
|
||||||
|
schedules: List<ScheduleEntity>,
|
||||||
|
dayEntities: List<DayEntity>,
|
||||||
|
lessonEntities: List<LessonEntity>
|
||||||
|
): Schedule? {
|
||||||
|
if (schedules.isEmpty()) return null
|
||||||
|
val groupId = schedules.first().groupId
|
||||||
|
|
||||||
|
val periods = schedules.sortedBy { it.weekStart }.map {
|
||||||
|
SchedulePeriod(
|
||||||
|
weekStart = LocalDate.ofEpochDay(it.weekStart),
|
||||||
|
weekEnd = LocalDate.ofEpochDay(it.weekEnd),
|
||||||
|
season = it.season
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lessonsByDay = lessonEntities.groupBy { it.dayId }
|
||||||
|
val days = dayEntities
|
||||||
|
.sortedBy { it.date }
|
||||||
|
.distinctBy { it.date }
|
||||||
|
.map { day ->
|
||||||
|
val lessons = lessonsByDay[day.id].orEmpty()
|
||||||
|
.groupBy { it.slotIndex }
|
||||||
|
.toSortedMap()
|
||||||
|
.map { (_, slotLessons) ->
|
||||||
|
TimeSlot(
|
||||||
|
start = LocalTime.of(slotLessons.first().start / 60, slotLessons.first().start % 60),
|
||||||
|
end = LocalTime.of(slotLessons.first().end / 60, slotLessons.first().end % 60),
|
||||||
|
lessons = slotLessons
|
||||||
|
.sortedBy { it.lessonIndex }
|
||||||
|
.map { it.toDomain() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Day(date = LocalDate.ofEpochDay(day.date), intervals = lessons)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Schedule(groupId = groupId, periods = periods, days = days)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LessonEntity.toDomain(): Lesson = Lesson(
|
||||||
|
raw = raw,
|
||||||
|
room = room,
|
||||||
|
group = groupName,
|
||||||
|
subgroup = subgroup,
|
||||||
|
subject = subject,
|
||||||
|
type = type,
|
||||||
|
teacher = teacher
|
||||||
|
)
|
||||||
|
|
||||||
private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
|
private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
|
||||||
return when (season) {
|
return when (season) {
|
||||||
Season.AUTUMN -> if (group.autumnFirst.isNotEmpty() && group.autumnSecond.isNotEmpty()) {
|
Season.AUTUMN -> if (group.autumnFirst != null && group.autumnSecond != null) {
|
||||||
DateRange(group.autumnFirst, group.autumnSecond)
|
DateRange(
|
||||||
|
LocalDate.ofEpochDay(group.autumnFirst).format(RANGE_FMT),
|
||||||
|
LocalDate.ofEpochDay(group.autumnSecond).format(RANGE_FMT)
|
||||||
|
)
|
||||||
} else null
|
} else null
|
||||||
Season.SPRING -> if (group.springFirst.isNotEmpty() && group.springSecond.isNotEmpty()) {
|
Season.SPRING -> if (group.springFirst != null && group.springSecond != null) {
|
||||||
DateRange(group.springFirst, group.springSecond)
|
DateRange(
|
||||||
|
LocalDate.ofEpochDay(group.springFirst).format(RANGE_FMT),
|
||||||
|
LocalDate.ofEpochDay(group.springSecond).format(RANGE_FMT)
|
||||||
|
)
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun now(): Long = System.currentTimeMillis()
|
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
|
||||||
|
|
||||||
private fun ScheduleEntity.toDomain(): Schedule = Schedule(
|
private fun now(): Long = System.currentTimeMillis()
|
||||||
groupId = groupId,
|
|
||||||
season = Season.valueOf(season),
|
|
||||||
days = codec.fromJson(weeksJson)
|
|
||||||
)
|
|
||||||
|
|
||||||
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 = 3
|
||||||
private const val TAG = "ScheduleRepo"
|
private const val TAG = "ScheduleRepo"
|
||||||
|
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package agzam.vyatsu.di
|
package agzam.vyatsu.di
|
||||||
|
|
||||||
import agzam.vyatsu.data.local.AppDatabase
|
import agzam.vyatsu.data.local.AppDatabase
|
||||||
import agzam.vyatsu.data.local.ScheduleCodec
|
|
||||||
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.repository.GroupRepositoryImpl
|
import agzam.vyatsu.data.repository.GroupRepositoryImpl
|
||||||
@@ -10,20 +9,18 @@ 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
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
|
|
||||||
class AppContainer(context: Context) {
|
class AppContainer(context: Context) {
|
||||||
private val moshi: Moshi = Moshi.Builder().build()
|
|
||||||
|
|
||||||
private val database: AppDatabase = Room.databaseBuilder(
|
private val database: AppDatabase = Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"vyatsu_schedule.db"
|
"vyatsu_schedule.db"
|
||||||
).build()
|
)
|
||||||
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
private val codec: ScheduleCodec = ScheduleCodec(moshi)
|
.build()
|
||||||
|
|
||||||
val groupRepository: GroupRepository by lazy {
|
val groupRepository: GroupRepository by lazy {
|
||||||
GroupRepositoryImpl(
|
GroupRepositoryImpl(
|
||||||
@@ -35,11 +32,14 @@ class AppContainer(context: Context) {
|
|||||||
ScheduleRepositoryImpl(
|
ScheduleRepositoryImpl(
|
||||||
scheduleDao = database.scheduleDao(),
|
scheduleDao = database.scheduleDao(),
|
||||||
groupDao = database.groupDao(),
|
groupDao = database.groupDao(),
|
||||||
codec = codec
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
package agzam.vyatsu.domain.model
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
/**
|
import java.time.LocalDate
|
||||||
* Расписание группы, как правило содержит 2 недели
|
|
||||||
* @author Agzam
|
data class SchedulePeriod(
|
||||||
*/
|
val weekStart: LocalDate,
|
||||||
|
val weekEnd: LocalDate,
|
||||||
|
val season: Season
|
||||||
|
)
|
||||||
|
|
||||||
data class Schedule(
|
data class Schedule(
|
||||||
|
|
||||||
val groupId: String,
|
val groupId: String,
|
||||||
val season: Season,
|
val periods: List<SchedulePeriod>,
|
||||||
|
|
||||||
/** Может содержать более 7 дней */
|
|
||||||
val days: List<Day>
|
val days: List<Day>
|
||||||
|
|
||||||
) {
|
) {
|
||||||
|
fun periodOf(date: LocalDate): SchedulePeriod? =
|
||||||
|
periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) }
|
||||||
|
|
||||||
|
fun daysOf(period: SchedulePeriod): List<Day> =
|
||||||
|
days.filter { !it.date.isBefore(period.weekStart) && !it.date.isAfter(period.weekEnd) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,11 @@ interface PreferencesRepository {
|
|||||||
suspend fun selectedGroupId(): String?
|
suspend fun selectedGroupId(): String?
|
||||||
suspend fun setSelectedGroup(groupId: String)
|
suspend fun setSelectedGroup(groupId: String)
|
||||||
suspend fun clearSelectedGroup()
|
suspend fun clearSelectedGroup()
|
||||||
|
suspend fun selectedWeekStart(): String?
|
||||||
|
suspend fun setSelectedWeekStart(weekStart: String)
|
||||||
|
suspend fun selectedDayDate(): String?
|
||||||
|
suspend fun setSelectedDayDate(date: String)
|
||||||
|
|
||||||
|
suspend fun autoUpdateEnabled(): Boolean
|
||||||
|
suspend fun setAutoUpdateEnabled(enabled: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package agzam.vyatsu.domain.repository
|
package agzam.vyatsu.domain.repository
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Schedule
|
import agzam.vyatsu.domain.model.Schedule
|
||||||
|
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ScheduleRepository {
|
interface ScheduleRepository {
|
||||||
@@ -8,5 +9,6 @@ interface ScheduleRepository {
|
|||||||
suspend fun getSchedule(groupId: String): Schedule?
|
suspend fun getSchedule(groupId: String): Schedule?
|
||||||
suspend fun sync(groupId: String): Schedule?
|
suspend fun sync(groupId: String): Schedule?
|
||||||
suspend fun isStale(groupId: String): Boolean
|
suspend fun isStale(groupId: String): Boolean
|
||||||
suspend fun clear(groupId: String)
|
suspend fun clearPeriod(groupId: String, period: SchedulePeriod)
|
||||||
|
suspend fun clearAll(groupId: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import agzam.vyatsu.ui.info.BuildingsScreen
|
|||||||
import agzam.vyatsu.ui.info.CallsScreen
|
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.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
|
||||||
import androidx.compose.material.icons.filled.DateRange
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
@@ -25,12 +28,14 @@ import androidx.compose.ui.Modifier
|
|||||||
private enum class HomeTab(val label: String) {
|
private enum class HomeTab(val label: String) {
|
||||||
SCHEDULE("Расписание"),
|
SCHEDULE("Расписание"),
|
||||||
CALLS("Звонки"),
|
CALLS("Звонки"),
|
||||||
BUILDINGS("Корпуса")
|
BUILDINGS("Корпуса"),
|
||||||
|
SETTINGS("Настройки")
|
||||||
}
|
}
|
||||||
|
|
||||||
@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) }
|
||||||
@@ -48,6 +53,7 @@ fun HomeScreen(
|
|||||||
HomeTab.SCHEDULE -> Icons.Default.DateRange
|
HomeTab.SCHEDULE -> Icons.Default.DateRange
|
||||||
HomeTab.CALLS -> Icons.Default.Info
|
HomeTab.CALLS -> Icons.Default.Info
|
||||||
HomeTab.BUILDINGS -> Icons.Default.Home
|
HomeTab.BUILDINGS -> Icons.Default.Home
|
||||||
|
HomeTab.SETTINGS -> Icons.Default.Settings
|
||||||
},
|
},
|
||||||
contentDescription = t.label
|
contentDescription = t.label
|
||||||
)
|
)
|
||||||
@@ -67,6 +73,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package agzam.vyatsu.ui.schedule
|
package agzam.vyatsu.ui.schedule
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Day
|
import agzam.vyatsu.domain.model.Day
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
|
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
@@ -11,13 +15,18 @@ 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.systemBars
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
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.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
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.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
@@ -31,10 +40,15 @@ import androidx.compose.material3.TopAppBar
|
|||||||
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
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -44,7 +58,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Расписание") },
|
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(onClick = { onSwitchGroup() }) {
|
TextButton(onClick = { onSwitchGroup() }) {
|
||||||
Text("Сменить группу")
|
Text("Сменить группу")
|
||||||
@@ -53,9 +67,9 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top)
|
windowInsets = WindowInsets(0.dp)
|
||||||
)
|
)
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
||||||
when {
|
when {
|
||||||
state.isSyncing && state.schedule == null -> {
|
state.isSyncing && state.schedule == null -> {
|
||||||
Row(
|
Row(
|
||||||
@@ -83,27 +97,44 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val schedule = state.schedule!!
|
val schedule = state.schedule!!
|
||||||
val day = schedule.days.getOrNull(state.selectedIndex)
|
val weekDays = state.weekStart
|
||||||
|
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
|
||||||
|
?.let { schedule.daysOf(it) }
|
||||||
|
.orEmpty()
|
||||||
|
val day = schedule.days.firstOrNull { it.date == state.dayDate }
|
||||||
|
|
||||||
if (day == null) {
|
if (day == null) {
|
||||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||||
} else {
|
} else {
|
||||||
val isToday = state.selectedIndex == state.todayIndex
|
val isTodayDay = day.date == state.todayDate
|
||||||
val dayLabel = if (isToday) "Сегодня " else ""
|
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||||
|
|
||||||
Text(
|
Row(
|
||||||
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.titleMedium
|
verticalAlignment = Alignment.CenterVertically
|
||||||
)
|
) {
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
Text(
|
||||||
|
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
WeekSelector(
|
||||||
|
periods = schedule.periods,
|
||||||
|
selectedWeekStart = state.weekStart,
|
||||||
|
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
|
||||||
|
onSelect = viewModel::selectWeek
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp))
|
||||||
|
|
||||||
DayChips(
|
DayChips(
|
||||||
days = schedule.days,
|
days = weekDays,
|
||||||
selectedIndex = state.selectedIndex,
|
selectedDate = state.dayDate,
|
||||||
todayIndex = state.todayIndex,
|
todayDate = state.todayDate,
|
||||||
onSelect = viewModel::selectDay
|
onSelect = viewModel::selectDay
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
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)
|
||||||
@@ -124,20 +155,85 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun WeekSelector(
|
||||||
|
periods: List<SchedulePeriod>,
|
||||||
|
selectedWeekStart: LocalDate?,
|
||||||
|
todayWeekStart: LocalDate?,
|
||||||
|
onSelect: (LocalDate) -> Unit
|
||||||
|
) {
|
||||||
|
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.clickable(enabled = true, onClick = { expanded = true })
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
selected?.let(::weekLabel) ?: "Период",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
modifier = Modifier.widthIn(min = 220.dp)
|
||||||
|
) {
|
||||||
|
periods.forEach { period ->
|
||||||
|
val isTodayWeek = todayWeekStart == period.weekStart
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
weekLabel(period),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = if (isTodayWeek) {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
"сегодня",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelect(period.weekStart)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayChips(
|
private fun DayChips(
|
||||||
days: List<Day>,
|
days: List<Day>,
|
||||||
selectedIndex: Int,
|
selectedDate: LocalDate?,
|
||||||
todayIndex: Int,
|
todayDate: LocalDate?,
|
||||||
onSelect: (Int) -> Unit
|
onSelect: (LocalDate) -> Unit
|
||||||
) {
|
) {
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
itemsIndexed(days) { index, day ->
|
items(days, key = { it.date.toEpochDay() }) { day ->
|
||||||
val selected = selectedIndex == index
|
val selected = selectedDate == day.date
|
||||||
val isTodayChip = todayIndex == index
|
val isTodayChip = todayDate == day.date
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selected,
|
selected = selected,
|
||||||
onClick = { onSelect(index) },
|
onClick = { onSelect(day.date) },
|
||||||
label = {
|
label = {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
@@ -172,6 +268,11 @@ private fun DayChips(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun weekLabel(period: SchedulePeriod): String {
|
||||||
|
val first = period.weekStart
|
||||||
|
return "с ${first.format(CHIP_DATE)}"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
@@ -185,18 +286,48 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
|||||||
} else {
|
} else {
|
||||||
slot.lessons.forEach { lesson ->
|
slot.lessons.forEach { lesson ->
|
||||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
Text(
|
var any = false
|
||||||
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
|
if(lesson.type != LessonType.OTHER) {
|
||||||
style = MaterialTheme.typography.bodyLarge
|
Text(
|
||||||
)
|
lesson.type.label,
|
||||||
Text(
|
style = MaterialTheme.typography.bodySmall,
|
||||||
listOf(lesson.type.label, lesson.subgroup, lesson.teacher, lesson.room)
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
.filterNotNull()
|
)
|
||||||
.filter { it.isNotBlank() }
|
any = true
|
||||||
.joinToString(" · "),
|
}
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,4 +346,4 @@ private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
|
|||||||
|
|
||||||
private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
||||||
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package agzam.vyatsu.ui.schedule
|
package agzam.vyatsu.ui.schedule
|
||||||
|
|
||||||
import agzam.vyatsu.domain.ScheduleDates
|
import agzam.vyatsu.domain.ScheduleDates
|
||||||
import agzam.vyatsu.domain.model.Day
|
|
||||||
import agzam.vyatsu.domain.model.Schedule
|
import agzam.vyatsu.domain.model.Schedule
|
||||||
|
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 androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
@@ -11,10 +11,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
class ScheduleViewModel(
|
class ScheduleViewModel(
|
||||||
private val scheduleRepository: ScheduleRepository,
|
private val scheduleRepository: ScheduleRepository,
|
||||||
private val preferencesRepository: PreferencesRepository
|
private val preferencesRepository: PreferencesRepository,
|
||||||
|
private val groupRepository: GroupRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ScheduleUiState())
|
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||||
@@ -28,7 +30,7 @@ class ScheduleViewModel(
|
|||||||
if (groupId != null) {
|
if (groupId != null) {
|
||||||
selectGroup(groupId)
|
selectGroup(groupId)
|
||||||
} else {
|
} else {
|
||||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
_uiState.value = _uiState.value.copy(noGroup = true, groupName = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,32 +39,105 @@ class ScheduleViewModel(
|
|||||||
currentGroupId = groupId
|
currentGroupId = groupId
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.setSelectedGroup(groupId)
|
preferencesRepository.setSelectedGroup(groupId)
|
||||||
_uiState.value = _uiState.value.copy(noGroup = false)
|
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
||||||
|
|
||||||
|
val savedWeekStart = runCatching {
|
||||||
|
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
|
||||||
|
}.getOrNull()
|
||||||
|
val savedDayDate = runCatching {
|
||||||
|
preferencesRepository.selectedDayDate()?.let(LocalDate::parse)
|
||||||
|
}.getOrNull()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
noGroup = false,
|
||||||
|
groupName = groupName,
|
||||||
|
weekStart = savedWeekStart,
|
||||||
|
dayDate = savedDayDate
|
||||||
|
)
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
scheduleRepository.observeSchedule(groupId).collect { schedule ->
|
scheduleRepository.observeSchedule(groupId).collect { schedule ->
|
||||||
currentGroupId?.let {
|
currentGroupId?.let { applySchedule(schedule) }
|
||||||
val days = schedule?.days.orEmpty()
|
|
||||||
val today = todayIndex(days)
|
|
||||||
val current = _uiState.value
|
|
||||||
val selected = current.selectedIndex.takeIf { i -> i in days.indices } ?: today
|
|
||||||
_uiState.value = current.copy(
|
|
||||||
schedule = schedule,
|
|
||||||
todayIndex = today,
|
|
||||||
selectedIndex = selected
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
_uiState.value = _uiState.value.copy(isSyncing = true)
|
syncIfMissingToday(groupId)
|
||||||
scheduleRepository.sync(groupId)
|
|
||||||
_uiState.value = _uiState.value.copy(isSyncing = false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun applySchedule(schedule: Schedule?) {
|
||||||
|
val current = _uiState.value
|
||||||
|
if (schedule == null || schedule.periods.isEmpty()) {
|
||||||
|
_uiState.value = current.copy(schedule = schedule, todayDate = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val today = ScheduleDates.moscowToday()
|
||||||
|
val todayDate = if (schedule.periodOf(today) != null) today else null
|
||||||
|
|
||||||
|
val weekStart = when {
|
||||||
|
schedule.periods.any { it.weekStart == current.weekStart } -> current.weekStart!!
|
||||||
|
todayDate != null -> schedule.periodOf(todayDate)!!.weekStart
|
||||||
|
else -> schedule.periods.first().weekStart
|
||||||
|
}
|
||||||
|
val period = schedule.periods.first { it.weekStart == weekStart }
|
||||||
|
val weekDays = schedule.daysOf(period)
|
||||||
|
|
||||||
|
val dayDate = when {
|
||||||
|
weekDays.any { it.date == current.dayDate } -> current.dayDate
|
||||||
|
weekDays.any { it.date == todayDate } -> todayDate
|
||||||
|
else -> weekDays.firstOrNull()?.date
|
||||||
|
}
|
||||||
|
|
||||||
|
preferencesRepository.setSelectedWeekStart(weekStart.toString())
|
||||||
|
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
|
||||||
|
|
||||||
|
_uiState.value = current.copy(
|
||||||
|
schedule = schedule,
|
||||||
|
weekStart = weekStart,
|
||||||
|
dayDate = dayDate,
|
||||||
|
todayDate = todayDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectWeek(weekStart: LocalDate) {
|
||||||
|
val state = _uiState.value
|
||||||
|
val schedule = state.schedule ?: return
|
||||||
|
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val today = ScheduleDates.moscowToday()
|
||||||
|
val weekDays = schedule.daysOf(period)
|
||||||
|
val dayDate = when {
|
||||||
|
weekDays.any { it.date == today } -> today
|
||||||
|
else -> weekDays.firstOrNull()?.date
|
||||||
|
}
|
||||||
|
preferencesRepository.setSelectedWeekStart(weekStart.toString())
|
||||||
|
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
|
||||||
|
_uiState.value = _uiState.value.copy(weekStart = weekStart, dayDate = dayDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectDay(date: LocalDate) {
|
||||||
|
val state = _uiState.value
|
||||||
|
val schedule = state.schedule ?: return
|
||||||
|
val period = schedule.periodOf(date) ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.setSelectedWeekStart(period.weekStart.toString())
|
||||||
|
preferencesRepository.setSelectedDayDate(date.toString())
|
||||||
|
_uiState.value = _uiState.value.copy(weekStart = period.weekStart, dayDate = date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncIfMissingToday(groupId: String) {
|
||||||
|
val schedule = scheduleRepository.getSchedule(groupId)
|
||||||
|
val today = ScheduleDates.moscowToday()
|
||||||
|
val hasTodayPeriod = schedule?.periodOf(today) != null
|
||||||
|
if (hasTodayPeriod) return
|
||||||
|
_uiState.value = _uiState.value.copy(isSyncing = true)
|
||||||
|
scheduleRepository.sync(groupId)
|
||||||
|
_uiState.value = _uiState.value.copy(isSyncing = false)
|
||||||
|
}
|
||||||
|
|
||||||
fun switchGroup() {
|
fun switchGroup() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
currentGroupId = null
|
currentGroupId = null
|
||||||
@@ -76,30 +151,25 @@ class ScheduleViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
||||||
scheduleRepository.sync(groupId)
|
scheduleRepository.sync(groupId)
|
||||||
val current = _uiState.value
|
_uiState.value = _uiState.value.copy(isRefreshing = false)
|
||||||
val days = current.schedule?.days.orEmpty()
|
|
||||||
val today = todayIndex(days)
|
|
||||||
_uiState.value = current.copy(
|
|
||||||
isRefreshing = false,
|
|
||||||
todayIndex = today,
|
|
||||||
selectedIndex = current.selectedIndex.takeIf { it in days.indices } ?: today
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectDay(index: Int) {
|
fun clearAll() {
|
||||||
val days = _uiState.value.schedule?.days.orEmpty()
|
val groupId = currentGroupId ?: return
|
||||||
if (index !in days.indices) return
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(selectedIndex = index)
|
scheduleRepository.clearAll(groupId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun todayIndex(days: List<Day>): Int {
|
fun clearSelectedPeriod() {
|
||||||
if (days.isEmpty()) return 0
|
val state = _uiState.value
|
||||||
val today = ScheduleDates.moscowToday()
|
val schedule = state.schedule ?: return
|
||||||
val exact = days.indexOfFirst { it.date == today }
|
val weekStart = state.weekStart ?: return
|
||||||
if (exact >= 0) return exact
|
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
|
||||||
val next = days.indexOfFirst { !it.date.isBefore(today) }
|
viewModelScope.launch {
|
||||||
return if (next >= 0) next else days.lastIndex
|
scheduleRepository.clearPeriod(schedule.groupId, period)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +178,8 @@ data class ScheduleUiState(
|
|||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val isSyncing: Boolean = false,
|
val isSyncing: Boolean = false,
|
||||||
val schedule: Schedule? = null,
|
val schedule: Schedule? = null,
|
||||||
val todayIndex: Int = 0,
|
val groupName: String? = null,
|
||||||
val selectedIndex: Int = 0
|
val weekStart: LocalDate? = null,
|
||||||
|
val dayDate: LocalDate? = null,
|
||||||
|
val todayDate: LocalDate? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package agzam.vyatsu.ui.settings
|
||||||
|
|
||||||
|
import agzam.vyatsu.BuildConfig
|
||||||
|
import agzam.vyatsu.update.UpdateState
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(viewModel: UpdateViewModel) {
|
||||||
|
val autoEnabled by viewModel.autoEnabled.collectAsState()
|
||||||
|
val updateState by viewModel.updateState.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
) {
|
||||||
|
item { SectionHeader("Обновления") }
|
||||||
|
item {
|
||||||
|
SettingsItem(
|
||||||
|
title = "Автоматические обновления",
|
||||||
|
subtitle = "Проверять и устанавливать новые версии при запуске",
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = autoEnabled,
|
||||||
|
onCheckedChange = { viewModel.setAutoEnabled(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SettingsItem(
|
||||||
|
title = "Проверить наличие обновлений",
|
||||||
|
subtitle = updateStatusText(updateState),
|
||||||
|
onClick = { viewModel.checkNow() }
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
}
|
||||||
|
if (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): String = when (state) {
|
||||||
|
is UpdateState.Idle -> "Проверить сейчас"
|
||||||
|
is UpdateState.Checking -> "Проверка…"
|
||||||
|
is UpdateState.UpToDate -> "Установлена последняя версия"
|
||||||
|
is UpdateState.Available -> "Доступна версия ${state.version}"
|
||||||
|
is UpdateState.Downloading -> "Скачивание: ${state.progress?.let { "$it%" } ?: "…"}"
|
||||||
|
is UpdateState.ReadyToInstall -> "Обновление скачано, нажмите для установки"
|
||||||
|
is UpdateState.Failed -> "Ошибка: ${state.message}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installHint(state: UpdateState, viewModel: UpdateViewModel): String = when {
|
||||||
|
state is UpdateState.ReadyToInstall && !viewModel.canRequestInstalls() ->
|
||||||
|
"Разрешите установку приложений из этого источника"
|
||||||
|
else -> "Нажмите, чтобы продолжить"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(title: String) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsItem(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
onClick: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailing?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsDivider() {
|
||||||
|
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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(true)
|
||||||
|
val autoEnabled: StateFlow<Boolean> = _autoEnabled.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_autoEnabled.value = preferencesRepository.autoUpdateEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutoEnabled(enabled: Boolean) {
|
||||||
|
_autoEnabled.value = enabled
|
||||||
|
updater.setAutoEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkNow() = updater.checkForUpdate()
|
||||||
|
|
||||||
|
fun downloadAndInstall() = updater.downloadAndInstall()
|
||||||
|
|
||||||
|
fun installDownloadedApk() = updater.installDownloadedApk()
|
||||||
|
|
||||||
|
fun canRequestInstalls() = updater.canRequestInstalls()
|
||||||
|
|
||||||
|
fun openUnknownSourcesSettings() = updater.openUnknownSourcesSettings()
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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 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 {
|
||||||
|
runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installDownloadedApk() {
|
||||||
|
val apk = downloadedApk ?: return
|
||||||
|
install(apk)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutoEnabled(enabled: Boolean) {
|
||||||
|
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()) {
|
||||||
|
openUnknownSourcesSettings()
|
||||||
|
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) {
|
||||||
|
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,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 |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path
|
||||||
|
name="updates"
|
||||||
|
path="updates/" />
|
||||||
|
</paths>
|
||||||