Compare commits

...
Author SHA1 Message Date
Agzamus 9a96bd3dac ci: installing from /github.com/adoptium 2026-09-10 00:47:38 +03:00
Agzamus c4345f9dbe ci: bullseye repo archive mirror 2026-09-10 00:44:18 +03:00
Agzamus 4797811097 ci: ignore jdk Check-Valid-Until 2026-09-10 00:31:17 +03:00
Agzamus 2dfef426a7 ui: schedule striped copyable rows 2026-09-10 00:23:28 +03:00
Agzamus 97c19647cc ui: LessonSlot separator 2026-09-09 22:25:55 +03:00
Agzamus dea27bc8f1 ci: fixed workflow old apt jdk17 repo 2026-09-07 00:35:50 +03:00
Agzamus bafebfd253 fix: no auto download if updated disabled 2026-09-07 00:31:34 +03:00
Agzamus f19d65abb5 feat: update request when need 2026-09-06 17:23:55 +03:00
Agzamus 889c57bbba feat: autoupdater 2026-09-06 12:38:00 +03:00
Agzamus ec6a7174e8 feat: icon & name 2026-09-06 02:47:40 +03:00
18 changed files with 832 additions and 60 deletions
+11 -3
View File
@@ -30,9 +30,17 @@ jobs:
# Ставим JDK вручную и явно прописываем JAVA_HOME. # Ставим JDK вручную и явно прописываем JAVA_HOME.
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions. # actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
run: | run: |
apt-get update set -euxo pipefail
apt-get install -y openjdk-17-jdk-headless unzip wget cd /tmp
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" wget -q https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz
tar -xzf OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz -C /opt
mv /opt/jdk-17.0.12+7 /opt/java17
export JAVA_HOME=/opt/java17
export PATH=$JAVA_HOME/bin:$PATH
echo "JAVA_HOME=/opt/java17" >> $GITHUB_ENV
echo "$JAVA_HOME/bin" >> $GITHUB_PATH
java -version
javac -version
- name: Install Android SDK - name: Install Android SDK
# Устанавливаем command-line tools и нужные компоненты SDK, # Устанавливаем command-line tools и нужные компоненты SDK,
+12 -1
View File
@@ -3,13 +3,14 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".VyatsuApplication" android:name=".VyatsuApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@drawable/icon"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@@ -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>
+31
View File
@@ -5,6 +5,10 @@ import agzam.vyatsu.ui.groups.GroupPickerScreen
import agzam.vyatsu.ui.groups.GroupPickerViewModel import agzam.vyatsu.ui.groups.GroupPickerViewModel
import agzam.vyatsu.ui.home.HomeScreen import agzam.vyatsu.ui.home.HomeScreen
import agzam.vyatsu.ui.schedule.ScheduleViewModel import agzam.vyatsu.ui.schedule.ScheduleViewModel
import agzam.vyatsu.ui.settings.UpdateViewModel
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -20,8 +24,33 @@ 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()
val showFirstRunDialog by updateViewModel.showFirstRunDialog.collectAsState()
if (showFirstRunDialog) {
AlertDialog(
onDismissRequest = { updateViewModel.onFirstRunDecision(false) },
title = { Text("Автоматические обновления") },
text = {
Text(
"Разрешить автоматически проверять и устанавливать " +
"новые версии приложения?"
)
},
confirmButton = {
TextButton(onClick = { updateViewModel.onFirstRunDecision(true) }) {
Text("Разрешить")
}
},
dismissButton = {
TextButton(onClick = { updateViewModel.onFirstRunDecision(false) }) {
Text("Не надо")
}
}
)
}
if (state.noGroup) { if (state.noGroup) {
GroupPickerScreen( GroupPickerScreen(
@@ -32,6 +61,7 @@ fun AppRoot() {
} }
HomeScreen( HomeScreen(
scheduleViewModel = scheduleViewModel, scheduleViewModel = scheduleViewModel,
updateViewModel = updateViewModel,
onSwitchGroup = { scheduleViewModel.switchGroup() } onSwitchGroup = { scheduleViewModel.switchGroup() }
) )
} }
@@ -43,3 +73,4 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) } private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) } private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
@@ -10,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()
@@ -27,4 +27,16 @@ interface PreferencesDao {
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)") @Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
suspend fun setSelectedDayDate(date: String) suspend fun setSelectedDayDate(date: String)
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateEnabled'")
suspend fun autoUpdateEnabled(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateEnabled', :enabled)")
suspend fun setAutoUpdateEnabled(enabled: String)
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateAsked'")
suspend fun autoUpdateAsked(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateAsked', :asked)")
suspend fun setAutoUpdateAsked(asked: String)
} }
@@ -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
}
@@ -13,4 +13,10 @@ class PreferencesRepositoryImpl(
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart) override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
override suspend fun selectedDayDate(): String? = dao.selectedDayDate() override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date) override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
override suspend fun autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() == "true"
override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
override suspend fun autoUpdateAsked(): Boolean = dao.autoUpdateAsked() == "true"
override suspend fun setAutoUpdateAsked(asked: Boolean) = dao.setAutoUpdateAsked(asked.toString())
} }
@@ -9,6 +9,7 @@ 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
@@ -37,4 +38,8 @@ class AppContainer(context: Context) {
val preferencesRepository: PreferencesRepository by lazy { val preferencesRepository: PreferencesRepository by lazy {
PreferencesRepositoryImpl(database.preferencesDao()) PreferencesRepositoryImpl(database.preferencesDao())
} }
val appUpdater: AppUpdater by lazy {
AppUpdater(context.applicationContext, preferencesRepository)
}
} }
@@ -8,4 +8,10 @@ interface PreferencesRepository {
suspend fun setSelectedWeekStart(weekStart: String) suspend fun setSelectedWeekStart(weekStart: String)
suspend fun selectedDayDate(): String? suspend fun selectedDayDate(): String?
suspend fun setSelectedDayDate(date: String) suspend fun setSelectedDayDate(date: String)
suspend fun autoUpdateEnabled(): Boolean
suspend fun setAutoUpdateEnabled(enabled: Boolean)
suspend fun autoUpdateAsked(): Boolean
suspend fun setAutoUpdateAsked(asked: Boolean)
} }
@@ -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)
} }
} }
} }
@@ -4,23 +4,31 @@ import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.LessonType import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.SchedulePeriod import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot import agzam.vyatsu.domain.model.TimeSlot
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
@@ -46,14 +54,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
val hPadding = 16.dp
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) { fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
@@ -69,7 +82,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}, },
windowInsets = WindowInsets(0.dp) windowInsets = WindowInsets(0.dp)
) )
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
when { when {
state.isSyncing && state.schedule == null -> { state.isSyncing && state.schedule == null -> {
Row( Row(
@@ -110,7 +123,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
val dayLabel = if (isTodayDay) "Сегодня " else "" val dayLabel = if (isTodayDay) "Сегодня " else ""
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth().padding(horizontal = hPadding),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
@@ -125,7 +138,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
onSelect = viewModel::selectWeek onSelect = viewModel::selectWeek
) )
} }
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp)) HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
DayChips( DayChips(
days = weekDays, days = weekDays,
@@ -134,14 +147,15 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
onSelect = viewModel::selectDay onSelect = viewModel::selectDay
) )
HorizontalDivider(Modifier.padding(top=0.dp, bottom = 8.dp)) HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
if (day.intervals.all { it.lessons.isEmpty() }) { if (day.intervals.all { it.lessons.isEmpty() }) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge) Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
itemsIndexed(day.intervals) { slotIndex, slot -> itemsIndexed(day.intervals) { slotIndex, slot ->
LessonSlot(slotIndex = slotIndex, slot = slot) LessonSlot(slotIndex = slotIndex, slot = slot)
@@ -227,7 +241,7 @@ private fun DayChips(
todayDate: LocalDate?, todayDate: LocalDate?,
onSelect: (LocalDate) -> Unit onSelect: (LocalDate) -> Unit
) { ) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
items(days, key = { it.date.toEpochDay() }) { day -> items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date val isTodayChip = todayDate == day.date
@@ -275,56 +289,92 @@ private fun weekLabel(period: SchedulePeriod): String {
@Composable @Composable
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) { private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
val context = LocalContext.current
var indexColor = 0
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
HorizontalDivider(
modifier = Modifier.width(10.dp + hPadding).padding(start = hPadding),
color = MaterialTheme.colorScheme.primary
)
Text( Text(
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}", "${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 4.dp)
) )
HorizontalDivider(color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f).padding(end = hPadding))
}
if (slot.lessons.isEmpty()) { if (slot.lessons.isEmpty()) {
Text("—", style = MaterialTheme.typography.bodyMedium) Text("—", style = MaterialTheme.typography.bodyMedium)
} else { } else {
slot.lessons.forEach { lesson -> slot.lessons.forEachIndexed { index, lesson ->
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { val backgroundColor = if (indexColor % 2 == 0) {
MaterialTheme.colorScheme.surfaceBright
} else {
MaterialTheme.colorScheme.surfaceVariant
}
indexColor++
// XXX:
// Убрать SelectionContainer?
// Режим редактирования расписания?
// Меню на onClick?
// Меню на onLongClick?
SelectionContainer() {
Column(
Modifier.fillMaxWidth().padding(vertical = 2.dp).background(backgroundColor)
// XXX: combinedClickable?
//.combinedClickable(
// onClick = {
// Toast.makeText(
// context,
// lesson.raw,
// Toast.LENGTH_SHORT
// ).show()
// },
// onLongClick = {},
// // TODO: onLongClickLabel = "(Accessibility)"
//),
) {
var any = false var any = false
if(lesson.type != LessonType.OTHER) {
Text(
lesson.type.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
any = true
}
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
if(subject.isNotEmpty()) {
Text(
subject,
style = MaterialTheme.typography.bodyLarge
)
any = true
}
val meta = listOfNotNull( val meta = listOfNotNull(
if (lesson.type == LessonType.OTHER) {
null
} else {
lesson.type.label
},
lesson.subgroup, lesson.subgroup,
lesson.teacher, lesson.teacher,
lesson.room lesson.room
).filter { it.isNotBlank() }.joinToString(" · ") ).filter { it.isNotBlank() }.joinToString(" · ")
if(meta.isNotEmpty()) { if (meta.isNotEmpty()) {
Text( Text(
meta, meta,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = hPadding)
) )
any = true any = true
} }
if(!any) { val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
if (subject.isNotEmpty()) {
Text( Text(
"-", subject,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = hPadding)
)
any = true
}
if (!any) {
Text(
"пусто",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = hPadding)
) )
} }
@@ -332,6 +382,7 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
} }
} }
} }
}
} }
private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) { private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
@@ -0,0 +1,152 @@
package agzam.vyatsu.ui.settings
import agzam.vyatsu.BuildConfig
import agzam.vyatsu.update.UpdateState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SettingsScreen(viewModel: UpdateViewModel) {
val autoEnabled by viewModel.autoEnabled.collectAsState()
val updateState by viewModel.updateState.collectAsState()
LazyColumn(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars)
) {
item { SectionHeader("Обновления") }
item {
SettingsItem(
title = "Автоматические обновления",
subtitle = "Проверять и устанавливать новые версии при запуске",
trailing = {
Switch(
checked = autoEnabled,
onCheckedChange = { viewModel.setAutoEnabled(it) }
)
}
)
SettingsDivider()
}
item {
SettingsItem(
title = "Проверить наличие обновлений",
subtitle = updateStatusText(updateState, autoEnabled),
onClick = { viewModel.checkNow() }
)
SettingsDivider()
}
if (autoEnabled && (updateState is UpdateState.Available || updateState is UpdateState.ReadyToInstall)) {
item {
SettingsItem(
title = "Установить обновление",
subtitle = installHint(updateState, viewModel),
onClick = {
when (updateState) {
is UpdateState.Available -> viewModel.downloadAndInstall()
is UpdateState.ReadyToInstall -> viewModel.installDownloadedApk()
else -> {}
}
}
)
SettingsDivider()
}
}
item { SectionHeader("О приложении") }
item {
SettingsItem(
title = "Версия",
trailing = {
Text(
BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
SettingsDivider()
}
}
}
private fun updateStatusText(state: UpdateState, autoEnabled: Boolean): String = when (state) {
is UpdateState.Idle -> "Проверить сейчас"
is UpdateState.Checking -> "Проверка…"
is UpdateState.UpToDate -> "Установлена последняя версия"
is UpdateState.Available ->
if (autoEnabled) "Доступна версия ${state.version}"
else "Доступна версия ${state.version} — включите автообновления для установки"
is UpdateState.Downloading -> "Скачивание: ${state.progress?.let { "$it%" } ?: "…"}"
is UpdateState.ReadyToInstall -> "Обновление скачано, нажмите для установки"
is UpdateState.Failed -> "Ошибка: ${state.message}"
}
private fun installHint(state: UpdateState, viewModel: UpdateViewModel): String = when {
state is UpdateState.ReadyToInstall && !viewModel.canRequestInstalls() ->
"Разрешите установку приложений из этого источника"
else -> "Нажмите, чтобы продолжить"
}
@Composable
private fun SectionHeader(title: String) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
private fun SettingsItem(
title: String,
subtitle: String? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}
@Composable
private fun SettingsDivider() {
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
}
@@ -0,0 +1,58 @@
package agzam.vyatsu.ui.settings
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.update.AppUpdater
import agzam.vyatsu.update.UpdateState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class UpdateViewModel(
private val updater: AppUpdater,
private val preferencesRepository: PreferencesRepository
) : ViewModel() {
val updateState: StateFlow<UpdateState> = updater.state
private val _autoEnabled = MutableStateFlow(false)
val autoEnabled: StateFlow<Boolean> = _autoEnabled.asStateFlow()
private val _showFirstRunDialog = MutableStateFlow(false)
val showFirstRunDialog: StateFlow<Boolean> = _showFirstRunDialog.asStateFlow()
init {
viewModelScope.launch {
_autoEnabled.value = preferencesRepository.autoUpdateEnabled()
_showFirstRunDialog.value = !preferencesRepository.autoUpdateAsked()
}
}
fun setAutoEnabled(enabled: Boolean) {
_autoEnabled.value = enabled
updater.setAutoEnabled(enabled)
if (enabled && !updater.canRequestInstalls()) {
updater.openUnknownSourcesSettings()
}
}
fun onFirstRunDecision(enable: Boolean) {
_showFirstRunDialog.value = false
viewModelScope.launch {
preferencesRepository.setAutoUpdateAsked(true)
}
setAutoEnabled(enable)
}
fun checkNow() = updater.checkForUpdate()
fun downloadAndInstall() = updater.downloadAndInstall()
fun installDownloadedApk() = updater.installDownloadedApk()
fun canRequestInstalls() = updater.canRequestInstalls()
fun openUnknownSourcesSettings() = updater.openUnknownSourcesSettings()
}
@@ -0,0 +1,217 @@
package agzam.vyatsu.update
import agzam.vyatsu.BuildConfig
import agzam.vyatsu.data.remote.update.GiteaReleaseApi
import agzam.vyatsu.domain.repository.PreferencesRepository
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.core.content.FileProvider
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed interface UpdateState {
data object Idle : UpdateState
data object Checking : UpdateState
data object UpToDate : UpdateState
data class Available(val version: String, val notes: String?) : UpdateState
data class Downloading(val progress: Int?) : UpdateState
data object ReadyToInstall : UpdateState
data class Failed(val message: String) : UpdateState
}
class AppUpdater(
private val context: Context,
private val preferencesRepository: PreferencesRepository
) {
private val scope = CoroutineScope(Dispatchers.IO)
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
private val api = GiteaReleaseApi(client)
private val _state = MutableStateFlow<UpdateState>(UpdateState.Idle)
val state: StateFlow<UpdateState> = _state.asStateFlow()
private var job: Job? = null
private var pendingUpdate: AvailableUpdate? = null
private var downloadedApk: File? = null
fun autoCheckAndInstall() {
if (job?.isActive == true) return
job = scope.launch {
if (!preferencesRepository.autoUpdateEnabled()) return@launch
runCatching {
val update = check() ?: return@launch
install(download(update))
}.onFailure(::onUpdateFailed)
}
}
fun checkForUpdate() {
if (job?.isActive == true) return
job = scope.launch {
runCatching { check() }.onFailure(::onUpdateFailed)
}
}
fun downloadAndInstall() {
if (job?.isActive == true) return
val update = pendingUpdate ?: return
job = scope.launch {
if (!preferencesRepository.autoUpdateEnabled()) return@launch
runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
}
}
fun installDownloadedApk() {
val apk = downloadedApk ?: return
install(apk)
}
fun setAutoEnabled(enabled: Boolean) {
if (!enabled) {
job?.cancel()
if (_state.value is UpdateState.Downloading || _state.value is UpdateState.Checking) {
_state.value = UpdateState.Idle
}
}
scope.launch {
preferencesRepository.setAutoUpdateEnabled(enabled)
}
}
fun canRequestInstalls(): Boolean =
context.packageManager.canRequestPackageInstalls()
fun openUnknownSourcesSettings() {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}")
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
}
private suspend fun check(): AvailableUpdate? {
_state.value = UpdateState.Checking
val release = api.latestRelease()
if (release == null) {
_state.value = UpdateState.UpToDate
return null
}
val remoteVersion = release.tagName?.trimStart('v', 'V')
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME)) {
_state.value = UpdateState.UpToDate
return null
}
val apkUrl = api.apkDownloadUrl(release)
?: throw IOException("В релизе $remoteVersion нет APK-файла")
val update = AvailableUpdate(version = remoteVersion, apkUrl = apkUrl)
pendingUpdate = update
_state.value = UpdateState.Available(update.version, release.body)
return update
}
private suspend fun download(update: AvailableUpdate): File {
_state.value = UpdateState.Downloading(null)
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val target = File(dir, "update-${update.version}.apk")
downloadedApk?.delete()
val response = client.newCall(Request.Builder().url(update.apkUrl).get().build()).execute()
response.use { resp ->
if (!resp.isSuccessful) throw IOException("HTTP ${resp.code} при скачивании")
val body = resp.body ?: throw IOException("Пустой ответ при скачивании")
val contentLength = body.contentLength()
body.byteStream().use { input ->
target.outputStream().use { output ->
val buffer = ByteArray(BUFFER_SIZE_BYTES)
var readTotal = 0L
while (true) {
val read = input.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
readTotal += read
if (contentLength > 0) {
_state.value = UpdateState.Downloading((readTotal * 100 / contentLength).toInt())
}
}
}
}
}
if (target.length() == 0L) {
target.delete()
throw IOException("Скачанный файл пуст")
}
downloadedApk = target
_state.value = UpdateState.ReadyToInstall
return target
}
private fun install(apk: File) {
if (!canRequestInstalls()) {
return
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apk
)
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
.onFailure { Log.e(TAG, "Не удалось запустить установку", it) }
}
private fun onUpdateFailed(error: Throwable) {
if (error is CancellationException) throw error
Log.e(TAG, "Ошибка обновления", error)
_state.value = UpdateState.Failed(error.message ?: "Неизвестная ошибка")
}
private data class AvailableUpdate(val version: String, val apkUrl: String)
private fun isNewerVersion(remote: String, local: String): Boolean {
val remoteParts = remote.split('.').map { it.toLongOrNull() ?: 0L }
val localParts = local.split('.').map { it.toLongOrNull() ?: 0L }
val size = maxOf(remoteParts.size, localParts.size)
for (i in 0 until size) {
val r = remoteParts.getOrElse(i) { 0L }
val l = localParts.getOrElse(i) { 0L }
if (r != l) return r > l
}
return false
}
private companion object {
const val TAG = "AppUpdate"
const val BUFFER_SIZE_BYTES = 8 * 1024
}
}
+109
View File
@@ -0,0 +1,109 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="100dp"
android:height="100dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M1,6L1,18L19,18L19,6"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF5581D5"/>
<item android:offset="1" android:color="#FF4063CD"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF5581D5"/>
<item android:offset="1" android:color="#FF4063CD"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M19,18L1,18C12,18 19,14 19,6Z"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF6598E3"/>
<item android:offset="1" android:color="#FF74A2E6"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF6598E3"/>
<item android:offset="1" android:color="#FF74A2E6"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M4,4C4,2 6,2 6,4L14,4C14,2 16,2 16,4L19,4L19,6L1,6L1,4Z"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="1"
android:startY="2.5"
android:endX="19"
android:endY="2.5"
android:type="linear">
<item android:offset="0" android:color="#FF4260C4"/>
<item android:offset="1" android:color="#FF0E2CB0"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startX="1"
android:startY="2.5"
android:endX="19"
android:endY="2.5"
android:type="linear">
<item android:offset="0" android:color="#FF4260C4"/>
<item android:offset="1" android:color="#FF0E2CB0"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M3,8L17,8C18,8 18,9 17,9L3,9C2,9 2,8 3,8M3,11L14,11C15,11 15,12 14,12L3,12C2,12 2,11 3,11M4,14L12,14C13,14 13,15 12,15L5,15C4,15 4,14 5,14M3,14C4,14 4,15 3,15C2,15 2,14 3,14"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeColor="#00000000"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="2.25"
android:startY="8"
android:endX="17.75"
android:endY="8"
android:type="linear">
<item android:offset="0" android:color="#FF4260C4"/>
<item android:offset="1" android:color="#FF0E2CB0"/>
</gradient>
</aapt:attr>
</path>
</vector>
+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100px" height="100px" viewBox="-10 -10 20 20">
<defs>
<linearGradient id="accent">
<stop offset="0%" stop-color="#4260c4" />
<stop offset="100%" stop-color="#0e2cb0" />
</linearGradient>
<linearGradient id="bg" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#5581d5" />
<stop offset="100%" stop-color="#4063cd" />
</linearGradient>
<linearGradient id="bga" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6598e3" />
<stop offset="100%" stop-color="#74a2e6" />
</linearGradient>
</defs>
<g stroke-width="0" stroke-linejoin="round" stroke-linecap="round">
<!-- <path stroke="#111" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/> -->
<path stroke="url(#bg)" fill="url(#bg)" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/>
<path stroke="url(#bga)" fill="url(#bga)" d="M 9 8 L -9 8 C 2 8 9 4 9 -4 Z"/>
<!-- <path stroke="#111" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/> -->
<g stroke="url(#accent)">
<path fill="url(#accent)" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/>
<path fill="url(#accent)" stroke="none" d="M -7 -2 L 7 -2 C 8 -2 8 -1 7 -1 L -7 -1 C -8 -1 -8 -2 -7 -2 M -7 1 L 4 1 C 5 1 5 2 4 2 L -7 2 C -8 2 -8 1 -7 1 M -6 4 L 2 4 C 3 4 3 5 2 5 L -5 5 C -6 5 -6 4 -5 4 M -7 4 C -6 4 -6 5 -7 5 C -8 5 -8 4 -7 4"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">VyatsuSchedule</string> <string name="app_name">ВятГУ Расписание</string>
</resources> </resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="updates"
path="updates/" />
</paths>