Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e05e8232 | ||
|
|
c4ecc43597 | ||
|
|
9a96bd3dac | ||
|
|
c4345f9dbe | ||
|
|
4797811097 | ||
|
|
2dfef426a7 | ||
|
|
97c19647cc | ||
|
|
dea27bc8f1 | ||
|
|
bafebfd253 | ||
|
|
f19d65abb5 |
@@ -30,9 +30,17 @@ jobs:
|
||||
# Ставим JDK вручную и явно прописываем JAVA_HOME.
|
||||
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y openjdk-17-jdk-headless unzip wget
|
||||
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||
set -euxo pipefail
|
||||
cd /tmp
|
||||
wget -q https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz
|
||||
tar -xzf OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz -C /opt
|
||||
mv /opt/jdk-17.0.12+7 /opt/java17
|
||||
export JAVA_HOME=/opt/java17
|
||||
export PATH=$JAVA_HOME/bin:$PATH
|
||||
echo "JAVA_HOME=/opt/java17" >> $GITHUB_ENV
|
||||
echo "$JAVA_HOME/bin" >> $GITHUB_PATH
|
||||
java -version
|
||||
javac -version
|
||||
|
||||
- name: Install Android SDK
|
||||
# Устанавливаем command-line tools и нужные компоненты SDK,
|
||||
|
||||
@@ -6,6 +6,9 @@ import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
||||
import agzam.vyatsu.ui.home.HomeScreen
|
||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -24,6 +27,30 @@ fun AppRoot() {
|
||||
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
||||
|
||||
val state by scheduleViewModel.uiState.collectAsState()
|
||||
val showFirstRunDialog by updateViewModel.showFirstRunDialog.collectAsState()
|
||||
|
||||
if (showFirstRunDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { updateViewModel.onFirstRunDecision(false) },
|
||||
title = { Text("Автоматические обновления") },
|
||||
text = {
|
||||
Text(
|
||||
"Разрешить автоматически проверять и устанавливать " +
|
||||
"новые версии приложения?"
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { updateViewModel.onFirstRunDecision(true) }) {
|
||||
Text("Разрешить")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { updateViewModel.onFirstRunDecision(false) }) {
|
||||
Text("Не надо")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.noGroup) {
|
||||
GroupPickerScreen(
|
||||
|
||||
@@ -33,4 +33,10 @@ interface PreferencesDao {
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ class PreferencesRepositoryImpl(
|
||||
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 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())
|
||||
}
|
||||
|
||||
@@ -11,4 +11,7 @@ interface PreferencesRepository {
|
||||
|
||||
suspend fun autoUpdateEnabled(): Boolean
|
||||
suspend fun setAutoUpdateEnabled(enabled: Boolean)
|
||||
|
||||
suspend fun autoUpdateAsked(): Boolean
|
||||
suspend fun setAutoUpdateAsked(asked: Boolean)
|
||||
}
|
||||
|
||||
@@ -4,23 +4,31 @@ import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
@@ -33,6 +41,7 @@ import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -46,14 +55,20 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
|
||||
val hPadding = 16.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -69,7 +84,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
},
|
||||
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 {
|
||||
state.isSyncing && state.schedule == null -> {
|
||||
Row(
|
||||
@@ -110,7 +125,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
Modifier.fillMaxWidth().padding(horizontal = hPadding),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
@@ -125,7 +140,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
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(
|
||||
days = weekDays,
|
||||
@@ -134,14 +149,14 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
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() }) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
itemsIndexed(day.intervals) { slotIndex, slot ->
|
||||
LessonSlot(slotIndex = slotIndex, slot = slot)
|
||||
@@ -227,7 +242,7 @@ private fun DayChips(
|
||||
todayDate: LocalDate?,
|
||||
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 ->
|
||||
val selected = selectedDate == day.date
|
||||
val isTodayChip = todayDate == day.date
|
||||
@@ -275,37 +290,62 @@ private fun weekLabel(period: SchedulePeriod): String {
|
||||
|
||||
@Composable
|
||||
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||
val context = LocalContext.current
|
||||
var indexColor = 0
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.width(10.dp + hPadding).padding(start = hPadding),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f).padding(end = hPadding))
|
||||
}
|
||||
if (slot.lessons.isEmpty()) {
|
||||
Text("—", style = MaterialTheme.typography.bodyMedium)
|
||||
} else {
|
||||
slot.lessons.forEach { lesson ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
slot.lessons.forEachIndexed { index, lesson ->
|
||||
var baseSurface = Modifier.fillMaxWidth().padding(vertical = 2.dp)
|
||||
if ((indexColor) % 2 != 0) {
|
||||
baseSurface = baseSurface.background(
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
);
|
||||
}
|
||||
indexColor++
|
||||
// XXX:
|
||||
// Убрать SelectionContainer?
|
||||
// Режим редактирования расписания?
|
||||
// Меню на onClick?
|
||||
// Меню на onLongClick?
|
||||
SelectionContainer() {
|
||||
Column(
|
||||
baseSurface
|
||||
// XXX: combinedClickable?
|
||||
//.combinedClickable(
|
||||
// onClick = {
|
||||
// Toast.makeText(
|
||||
// context,
|
||||
// lesson.raw,
|
||||
// Toast.LENGTH_SHORT
|
||||
// ).show()
|
||||
// },
|
||||
// onLongClick = {},
|
||||
// // TODO: onLongClickLabel = "(Accessibility)"
|
||||
//),
|
||||
) {
|
||||
var any = false
|
||||
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(
|
||||
if (lesson.type == LessonType.OTHER) {
|
||||
null
|
||||
} else {
|
||||
lesson.type.label
|
||||
},
|
||||
lesson.subgroup,
|
||||
lesson.teacher,
|
||||
lesson.room
|
||||
@@ -315,16 +355,28 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||
Text(
|
||||
meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = hPadding)
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
|
||||
if (subject.isNotEmpty()) {
|
||||
Text(
|
||||
subject,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = hPadding)
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
if (!any) {
|
||||
Text(
|
||||
"-",
|
||||
"пусто",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = hPadding)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -333,6 +385,7 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
|
||||
DayOfWeek.MONDAY -> "Пн"
|
||||
|
||||
@@ -50,12 +50,12 @@ fun SettingsScreen(viewModel: UpdateViewModel) {
|
||||
item {
|
||||
SettingsItem(
|
||||
title = "Проверить наличие обновлений",
|
||||
subtitle = updateStatusText(updateState),
|
||||
subtitle = updateStatusText(updateState, autoEnabled),
|
||||
onClick = { viewModel.checkNow() }
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
if (updateState is UpdateState.Available || updateState is UpdateState.ReadyToInstall) {
|
||||
if (autoEnabled && (updateState is UpdateState.Available || updateState is UpdateState.ReadyToInstall)) {
|
||||
item {
|
||||
SettingsItem(
|
||||
title = "Установить обновление",
|
||||
@@ -89,11 +89,13 @@ fun SettingsScreen(viewModel: UpdateViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStatusText(state: UpdateState): String = when (state) {
|
||||
private fun updateStatusText(state: UpdateState, autoEnabled: Boolean): String = when (state) {
|
||||
is UpdateState.Idle -> "Проверить сейчас"
|
||||
is UpdateState.Checking -> "Проверка…"
|
||||
is UpdateState.UpToDate -> "Установлена последняя версия"
|
||||
is UpdateState.Available -> "Доступна версия ${state.version}"
|
||||
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}"
|
||||
|
||||
@@ -17,18 +17,33 @@ class UpdateViewModel(
|
||||
|
||||
val updateState: StateFlow<UpdateState> = updater.state
|
||||
|
||||
private val _autoEnabled = MutableStateFlow(true)
|
||||
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()
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -75,6 +76,7 @@ class AppUpdater(
|
||||
if (job?.isActive == true) return
|
||||
val update = pendingUpdate ?: return
|
||||
job = scope.launch {
|
||||
if (!preferencesRepository.autoUpdateEnabled()) return@launch
|
||||
runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +87,12 @@ class AppUpdater(
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -166,7 +174,6 @@ class AppUpdater(
|
||||
|
||||
private fun install(apk: File) {
|
||||
if (!canRequestInstalls()) {
|
||||
openUnknownSourcesSettings()
|
||||
return
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(
|
||||
@@ -184,6 +191,7 @@ class AppUpdater(
|
||||
}
|
||||
|
||||
private fun onUpdateFailed(error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
Log.e(TAG, "Ошибка обновления", error)
|
||||
_state.value = UpdateState.Failed(error.message ?: "Неизвестная ошибка")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user