New materail3 UI

This commit is contained in:
2026-10-20 20:53:52 +03:00
parent 84da62c45d
commit 4fb1e3edca
33 changed files with 2198 additions and 85 deletions
@@ -1,5 +1,6 @@
package agzam.vyatsu.ui.home
import agzam.vyatsu.R
import agzam.vyatsu.ui.info.BuildingsScreen
import agzam.vyatsu.ui.info.CallsScreen
import agzam.vyatsu.ui.schedule.ScheduleScreen
@@ -8,18 +9,18 @@ import agzam.vyatsu.ui.settings.SettingsScreen
import agzam.vyatsu.ui.settings.UpdateViewModel
import agzam.vyatsu.ui.teacher.TeacherScheduleScreen
import agzam.vyatsu.ui.teacher.TeacherScheduleViewModel
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -27,6 +28,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
private enum class HomeTab(val label: String) {
SCHEDULE("Расписание"),
@@ -35,8 +38,12 @@ private enum class HomeTab(val label: String) {
SETTINGS("Настройки")
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
scheduleViewModel: ScheduleViewModel,
teacherScheduleViewModel: TeacherScheduleViewModel,
updateViewModel: UpdateViewModel,
@@ -47,19 +54,32 @@ fun HomeScreen(
val teacherState by teacherScheduleViewModel.uiState.collectAsState()
Scaffold(
modifier = Modifier,
bottomBar = {
NavigationBar {
NavigationBar { // Новое нижнее меню
HomeTab.entries.forEach { t ->
NavigationBarItem(
selected = tab == t,
onClick = { tab = t },
icon = {
Icon(
imageVector = when (t) {
HomeTab.SCHEDULE -> Icons.Default.DateRange
HomeTab.CALLS -> Icons.Default.Info
HomeTab.BUILDINGS -> Icons.Default.Home
HomeTab.SETTINGS -> Icons.Default.Settings
painter = when (t) {
HomeTab.SCHEDULE -> {
if (tab == t) painterResource(R.drawable.calendar_month_24dp_fill)
else painterResource(R.drawable.calendar_month_24dp_outline)
}
HomeTab.CALLS -> {
if (tab == t) painterResource(R.drawable.notifications_active_24dp_fill)
else painterResource(R.drawable.notifications_active_outline)
}
HomeTab.BUILDINGS -> {
if (tab == t) painterResource(R.drawable.location_on_24dp_fill)
else painterResource(R.drawable.location_on_24dp_outline)
}
HomeTab.SETTINGS -> {
if (tab == t) painterResource(R.drawable.settings_24dp_fill)
else painterResource(R.drawable.settings_24dp_outline)
}
},
contentDescription = t.label
)
@@ -70,7 +90,7 @@ fun HomeScreen(
}
}
) { innerPadding ->
androidx.compose.foundation.layout.Box(
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)
@@ -23,7 +23,156 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import agzam.vyatsu.BuildConfig
import agzam.vyatsu.R
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBar
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BuildingsScreen() { // Новый корпусов
val context = LocalContext.current
Scaffold(
modifier = Modifier,
contentWindowInsets = WindowInsets(0.dp),
topBar = {
TopAppBar(
windowInsets = WindowInsets(0.dp),
title = {
Text(
text = "Корпуса", // Заголовок
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
}
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 12.dp,
bottom = 24.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Column(
modifier = Modifier
.padding(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Расположение корпусов", // Еще заголовков
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
text = "Адреса учебных корпусов",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
items(BUILDINGS) { building ->
// Карточки корпусов
Card(
modifier = Modifier
.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
onClick = {
val uri = Uri.parse("geo:${building.latitude},${building.longitude}?q=${building.latitude},${building.longitude}(Корпус ${building.number})")
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
},
colors = CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
modifier = Modifier.size(60.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Box(
contentAlignment = Alignment.Center
) {
Text(
text = "${building.number}",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
// Название
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Корпус ${building.number}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = "${building.address}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Время
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon (
painter = painterResource(R.drawable.chevron_right_24dp),
contentDescription = "Enter"
)
}
}
}
}
}
}
}
/*
@Composable
fun BuildingsScreen() {
val context = LocalContext.current
@@ -56,3 +205,5 @@ fun BuildingsScreen() {
}
}
}
*/
@@ -1,21 +1,386 @@
package agzam.vyatsu.ui.info
import agzam.vyatsu.data.static.CALLS
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.delay
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CallsScreen() {
var now by remember {
mutableStateOf(LocalTime.now())
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
now = LocalTime.now()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(Unit) {
while (true) {
now = LocalTime.now()
delay(15_000L)
}
}
val intervals = remember {
val formatter = DateTimeFormatter.ofPattern("H:mm")
CALLS.mapIndexed { index, call ->
CallInterval(
number = index + 1,
start = LocalTime.parse(call.start, formatter),
end = LocalTime.parse(call.end, formatter)
)
}
}
val current = intervals.firstOrNull {
!now.isBefore(it.start) && now.isBefore(it.end)
}
val next = intervals.firstOrNull {
now.isBefore(it.start)
}
Scaffold(
contentWindowInsets = WindowInsets(0.dp),
topBar = {
TopAppBar(
windowInsets = WindowInsets(0.dp),
title = {
Text(
text = "Звонки",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
}
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(
horizontal = 16.dp,
vertical = 12.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
CallsStatusCard(
now = now,
current = current,
next = next,
first = intervals.firstOrNull()
)
}
items(
items = intervals,
key = { it.number }
) { interval ->
val isCurrent =
current?.number == interval.number
CallCard(
interval = interval,
now = now,
isCurrent = isCurrent
)
}
}
}
}
private data class CallInterval(
val number: Int,
val start: LocalTime,
val end: LocalTime
)
@Composable
private fun CallCard(
interval: CallInterval,
now: LocalTime,
isCurrent: Boolean
) {
val isFinished = !now.isBefore(interval.end)
val status = when {
isCurrent -> "Идёт сейчас"
isFinished -> "Завершена"
else -> "Впереди"
}
val totalSeconds = ChronoUnit.SECONDS.between(
interval.start,
interval.end
).coerceAtLeast(1)
val elapsedSeconds = ChronoUnit.SECONDS.between(
interval.start,
now
)
val progress = (
elapsedSeconds.toFloat() / totalSeconds
).coerceIn(0f, 1f)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = if (isCurrent) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerLow
}
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
modifier = Modifier.size(56.dp),
shape = RoundedCornerShape(16.dp),
color = if (isCurrent) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondaryContainer
},
contentColor = if (isCurrent) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSecondaryContainer
}
) {
Box(
contentAlignment = Alignment.Center
) {
Text(
text = interval.number.toString(),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = if (isCurrent) {
"Текущая пара"
} else {
"Пара"
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = interval.start.toString(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = interval.end.toString(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (isCurrent) {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
}
}
}
}
@Composable
private fun CallsStatusCard(
now: LocalTime,
current: CallInterval?,
next: CallInterval?,
first: CallInterval?
) {
val title: String
val subtitle: String
val progress: Float
when {
current != null -> {
title = "Сейчас идёт ${current.number} пара"
subtitle = "До конца ${minutesUntil(now, current.end)} мин"
val total = ChronoUnit.SECONDS.between(
current.start,
current.end
).coerceAtLeast(1)
val elapsed = ChronoUnit.SECONDS.between(
current.start,
now
)
progress = (
elapsed.toFloat() / total
).coerceIn(0f, 1f)
}
next != null -> {
title = if (first?.number == next.number) {
"Занятия ещё не начались"
} else {
"Сейчас перемена"
}
subtitle = "До ${next.number} пары ${minutesUntil(now, next.start)} мин"
progress = 0f
}
else -> {
title = "Занятия закончились"
subtitle = "На сегодня пар больше нет"
progress = 1f
}
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = subtitle,
style = MaterialTheme.typography.headlineSmall
)
if (current != null) {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
}
}
}
}
private fun minutesUntil(
now: LocalTime,
target: LocalTime
): Long {
val seconds = ChronoUnit.SECONDS.between(
now,
target
).coerceAtLeast(0)
return (seconds + 59) / 60
}
/*
// Агзам
@Composable
fun CallsScreen() {
LazyColumn(
@@ -40,3 +405,5 @@ fun CallsScreen() {
}
}
}
*/
@@ -1,29 +1,32 @@
package agzam.vyatsu.ui.picker
import agzam.vyatsu.R
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Teacher
import androidx.compose.foundation.clickable
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@@ -36,30 +39,429 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PickerScreen(
modifier: Modifier = Modifier,
viewModel: PickerViewModel,
onGroupPicked: (Group) -> Unit,
onTeacherPicked: (Teacher) -> Unit
onTeacherPicked: (Teacher) -> Unit,
) {
val state by viewModel.uiState.collectAsState()
var segment by rememberSaveable { mutableStateOf(PickerSegment.GROUPS) }
LaunchedEffect(segment)
{
viewModel.selectSegment(segment)
}
val keyboardController = LocalSoftwareKeyboardController.current
val groupsListState = rememberLazyListState()
val teachersListState = rememberLazyListState()
val listState = when (segment) {
PickerSegment.GROUPS -> groupsListState
PickerSegment.TEACHERS -> teachersListState
}
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.collect { isScrolling ->
if (isScrolling) {
keyboardController?.hide()
}
}
}
val query = when (segment) {
PickerSegment.GROUPS -> state.groupQuery
PickerSegment.TEACHERS -> state.teacherQuery
}
val onSearch: (String) -> Unit = when (segment) {
PickerSegment.GROUPS -> viewModel::searchGroup
PickerSegment.TEACHERS -> viewModel::searchTeacher
}
val placeholder = when (segment) {
PickerSegment.GROUPS -> "Поиск группы"
PickerSegment.TEACHERS -> "Поиск преподавателя"
}
val isLoading = when (segment) {
PickerSegment.GROUPS -> state.groupsLoading
PickerSegment.TEACHERS -> state.teachersLoading
}
val isEmpty = when (segment) {
PickerSegment.GROUPS -> state.groups.isEmpty()
PickerSegment.TEACHERS -> state.teachers.isEmpty()
}
val onRetry: () -> Unit = when (segment) {
PickerSegment.GROUPS -> viewModel::retryGroups
PickerSegment.TEACHERS -> viewModel::retryTeachers
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Кого ищем",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface
),
actions = {
IconButton(onClick = {
when (segment) {
PickerSegment.GROUPS -> viewModel.refreshGroups()
PickerSegment.TEACHERS -> viewModel.refreshTeachers()
}
}) {
Icon(
painter = painterResource(R.drawable.refresh_24dp),
contentDescription = "Обновить"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Выберите группу или преподавателя",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
SegmentedButton(
selected = segment == PickerSegment.GROUPS,
onClick = {
segment = PickerSegment.GROUPS
},
shape = SegmentedButtonDefaults.itemShape(
index = 0,
count = 2
),
icon = {
Icon(
painter = if (segment == PickerSegment.GROUPS)
painterResource(R.drawable.groups_24dp_fill)
else painterResource(R.drawable.groups_24dp_outline),
contentDescription = "Select group"
)
}
) {
Text("Группы")
}
SegmentedButton(
selected = segment == PickerSegment.TEACHERS,
onClick = {
segment = PickerSegment.TEACHERS
},
shape = SegmentedButtonDefaults.itemShape(
index = 1,
count = 2
),
icon = {
Icon(
painter = if (segment == PickerSegment.TEACHERS)
painterResource(R.drawable.person_24dp_fill)
else painterResource(R.drawable.person_24dp_outline),
contentDescription = "Select teacher"
)
}
) {
Text("Преподаватели")
}
}
Spacer(Modifier.padding(8.dp))
PickerSearchBar(
query = query,
onQueryChange = onSearch,
placeholder = placeholder
)
Spacer(Modifier.height(16.dp))
if (segment == PickerSegment.TEACHERS) {
val progress = state.syncProgress
if (progress != null &&
progress.total > 0 &&
progress.done < progress.total
) {
LinearProgressIndicator(
progress = {
progress.done.toFloat() /
progress.total.toFloat()
},
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Text(
text = "Кафедры: ${progress.done}/${progress.total}",
style = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(8.dp))
}
}
if (isLoading && isEmpty) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
} else if (state.error != null && isEmpty) {
Text(
text = state.error ?: "",
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = onRetry) {
Text("Повторить")
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp)
) {
when (segment) {
PickerSegment.GROUPS -> {
items(
items = state.filteredGroups,
key = { it.name }
) { group ->
PickerCard(
name = group.name,
segment = segment,
onClick = {
onGroupPicked(group)
}
)
}
}
PickerSegment.TEACHERS -> {
items(
items = state.filteredTeachers,
key = { it.name }
) { teacher ->
PickerCard(
name = teacher.name,
segment = segment,
onClick = {
onTeacherPicked(teacher)
}
)
}
}
}
}
}
}
}
}
@Composable
private fun PickerCard(
modifier: Modifier = Modifier,
name: String,
segment: PickerSegment,
onClick: () -> Unit,
) {
val iconRes = when (segment) {
PickerSegment.GROUPS -> R.drawable.groups_24dp_outline
PickerSegment.TEACHERS -> R.drawable.person_24dp_outline
}
Card(
modifier = modifier
.fillMaxWidth()
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
),
shape = MaterialTheme.shapes.large,
elevation = CardDefaults.cardElevation(
defaultElevation = 2.dp,
pressedElevation = 4.dp
),
onClick = onClick
) {
Row (
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = MaterialTheme.shapes.small
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(iconRes),
contentDescription = "Icons",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
Text (
text = name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Icon(
painter = painterResource(R.drawable.chevron_right_24dp),
contentDescription = "Select",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PickerSearchBar(
query: String,
onQueryChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier
) {
SearchBarDefaults.InputField(
query = query,
onQueryChange = onQueryChange,
onSearch = {},
expanded = false,
onExpandedChange = {},
modifier = modifier.fillMaxWidth(),
placeholder = {
Text(placeholder)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null
)
},
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(
onClick = { onQueryChange("") }
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Очистить поиск"
)
}
}
}
)
}
/*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PickerScreen(
modifier: Modifier = Modifier,
viewModel: PickerViewModel,
onGroupPicked: (Group) -> Unit,
onTeacherPicked: (Teacher) -> Unit,
) {
val state by viewModel.uiState.collectAsState()
var segment by rememberSaveable { mutableStateOf(PickerSegment.GROUPS) }
LaunchedEffect(segment) { viewModel.selectSegment(segment) }
Scaffold { innerPadding ->
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Кого ищем",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface
),
actions = {
IconButton(onClick = { onRefresh() } ) {
Icon(
painter = painterResource(R.drawable.refresh_24dp),
contentDescription = "Обновить"
)
}
}
)
}
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
@@ -333,4 +735,4 @@ private fun TeacherList(teachers: List<Teacher>, onPick: (Teacher) -> Unit) {
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}*/
@@ -201,4 +201,29 @@ class PickerViewModel(
fun retryTeachers() {
syncAndObserveTeachers()
}
// Добавил обновление списка преподов
fun refreshTeachers() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
teachersLoading = true,
error = null
)
try {
withContext(Dispatchers.IO) {
teacherRepository.forceRefresh()
}
syncAndObserveTeachers()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
teachersLoading = false,
error = e.message ?: "Не удалось обновить преподавателей"
)
}
}
}
}
@@ -1,8 +1,13 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.R
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.ui.schedule.components.DayChips
import agzam.vyatsu.ui.schedule.components.FULL_DATE
import agzam.vyatsu.ui.schedule.components.LessonSlot
import agzam.vyatsu.ui.schedule.components.ScheduleDateHeader
import agzam.vyatsu.ui.schedule.components.ScheduleEmptyDay
import agzam.vyatsu.ui.schedule.components.ScheduleError
import agzam.vyatsu.ui.schedule.components.WeekSelector
import agzam.vyatsu.ui.schedule.components.dayName
import agzam.vyatsu.ui.schedule.components.hPadding
@@ -17,7 +22,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
@@ -28,6 +35,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -38,8 +46,225 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScheduleScreen(
modifier: Modifier = Modifier,
viewModel: ScheduleViewModel,
onSwitchGroup: () -> Unit
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
topBar = {
TopAppBar(
windowInsets = WindowInsets(0.dp),
title = {
Column {
Text(
text = state.groupName ?: "Расписание",
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "Расписание занятий",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
actions = {
if (state.isLoading && state.schedule != null) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
}
IconButton(onClick = { viewModel.refresh() }) {
Icon(
painter = painterResource(R.drawable.refresh_24dp),
contentDescription = "Обновить"
)
}
IconButton(onClick = { onSwitchGroup() }) {
Icon(
painter = painterResource(R.drawable.sync_alt_24dp),
contentDescription = "Изменить выбор"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when {
state.schedule == null && state.loadError -> {
ScheduleError(
onRetry = viewModel::refresh
)
}
state.schedule == null -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
else -> {
val schedule = state.schedule!!
val weekDays = state.weekStart
?.let { weekStart ->
schedule.periods.firstOrNull {
it.weekStart == weekStart
}
}
?.let { period ->
schedule.daysOf(period)
}
.orEmpty()
if (weekDays.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Нет расписания на эту неделю")
}
} else {
val selectedDayIndex = weekDays
.indexOfFirst { it.date == state.dayDate }
.coerceAtLeast(0)
val pagerState = rememberPagerState(
initialPage = selectedDayIndex,
pageCount = { weekDays.size }
)
LaunchedEffect(
state.dayDate,
weekDays
) {
val targetPage = weekDays.indexOfFirst {
it.date == state.dayDate
}
if (
targetPage >= 0 &&
targetPage != pagerState.currentPage
) {
pagerState.scrollToPage(targetPage)
}
}
LaunchedEffect(pagerState, weekDays) {
snapshotFlow {
pagerState.settledPage
}.collect { page ->
val selectedDate =
weekDays.getOrNull(page)?.date
if (
selectedDate != null &&
selectedDate != state.dayDate
) {
viewModel.selectDay(selectedDate)
}
}
}
ScheduleDateHeader(
date = state.dayDate,
todayDate = state.todayDate,
periods = schedule.periods,
selectedWeekStart = state.weekStart,
onSelectWeek = viewModel::selectWeek
)
DayChips(
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
pagerState = pagerState,
onSelect = viewModel::selectDay
)
HorizontalDivider()
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f),
key = { page ->
weekDays[page].date.toEpochDay()
}
) { page ->
val day = weekDays.getOrNull(page)
if (day == null ||
day.intervals.all { it.lessons.isEmpty() }
) {
ScheduleEmptyDay()
} else {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement =
Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
top = 16.dp,
bottom = 24.dp
)
) {
items(
items = day.intervals.withIndex()
.filter { (_, slot) ->
slot.lessons.any { lesson ->
!lesson.subject.isNullOrBlank() ||
lesson.raw.isNotBlank()
}
},
key = { it.index }
) { (slotIndex, slot) ->
LessonSlot(
slotIndex = slotIndex,
slot = slot
)
}
}
}
}
}
}
}
}
}
}
/*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
@@ -187,3 +412,4 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}
}
}
*/
@@ -1,10 +1,13 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.Day
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyRow
@@ -15,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -22,6 +26,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.collectLatest
import java.time.LocalDate
@@ -90,44 +95,67 @@ internal fun DayChips(
}
}
}
LazyRow(
state = listState,
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
horizontal = hPadding,
vertical = 8.dp
)
) {
items(
items = days,
key = { it.date.toEpochDay() }
) { day ->
LazyRow(state = listState, horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date
FilterChip(
selected = selected,
val isToday = todayDate == day.date
Surface(
onClick = { onSelect(day.date) },
label = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
dayName(day.date.dayOfWeek),
style = MaterialTheme.typography.labelMedium
)
Text(
day.date.format(CHIP_DATE),
style = MaterialTheme.typography.labelSmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
modifier = Modifier.size(
width = 72.dp,
height = 72.dp
),
shape = RoundedCornerShape(20.dp),
color = if (selected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerLow
},
modifier = Modifier.padding(vertical = 2.dp),
border = if (isTodayChip && !selected) {
FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.primary,
borderWidth = 1.dp,
enabled = true,
selected = false
contentColor = if (selected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
border = if (isToday && !selected) {
BorderStroke(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
},
shape = RoundedCornerShape(8.dp)
)
null
}
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = dayName(day.date.dayOfWeek),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium
)
Text(
text = day.date.format(CHIP_DATE),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
@@ -3,12 +3,15 @@ package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -18,6 +21,94 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@Composable
internal fun LessonSlot(
slotIndex: Int,
slot: TimeSlot,
showTeacher: Boolean = true
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = hPadding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "${slotIndex + 1} пара",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "${slot.start.format(TIME)}–${slot.end.format(TIME)}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
slot.lessons.forEach { lesson ->
val subject = lesson.subject
?.takeIf { it.isNotBlank() }
?: lesson.raw
val details = listOfNotNull(
lesson.type.takeIf { it != LessonType.OTHER }?.label,
lesson.subgroup,
if (showTeacher) lesson.teacher else lesson.group,
lesson.room
).filter { it.isNotBlank() }
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = subject,
style = MaterialTheme.typography.titleMedium
)
if (details.isNotEmpty()) {
Text(
text = details.joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
lesson.department
?.takeIf { it.isNotBlank() }
?.let { department ->
HorizontalDivider()
Text(
text = department,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
/*
@Composable
internal fun LessonSlot(slotIndex: Int, slot: TimeSlot, showTeacher: Boolean = true) {
val context = LocalContext.current
@@ -125,4 +216,6 @@ internal fun LessonSlot(slotIndex: Int, slot: TimeSlot, showTeacher: Boolean = t
}
}
}
}
}
*/
@@ -0,0 +1,68 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.SchedulePeriod
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.LocalDate
@Composable
internal fun ScheduleDateHeader(
date: LocalDate?,
todayDate: LocalDate?,
periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?,
onSelectWeek: (LocalDate) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = hPadding,
vertical = 12.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = if (date == todayDate) {
"Сегодня"
} else {
"Выбранный день"
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = date?.let {
"${dayName(it.dayOfWeek)} · ${it.format(FULL_DATE)}"
} ?: "Дата не выбрана",
style = MaterialTheme.typography.titleMedium
)
}
// WeekSelector(
// periods = periods,
// selectedWeekStart = selectedWeekStart,
// todayWeekStart = todayDate?.let { today ->
// periods.firstOrNull { period ->
// !today.isBefore(period.weekStart) &&
// !today.isAfter(period.weekEnd)
// }?.weekStart
// },
// onSelect = onSelectWeek
// )
}
}
@@ -0,0 +1,73 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.R
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@Composable
internal fun ScheduleEmptyDay() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(R.drawable.event_busy_24dp),
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Занятий нет",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "На этот день расписание пустое",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
internal fun ScheduleError(
onRetry: () -> Unit
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Не удалось загрузить расписание",
style = MaterialTheme.typography.titleMedium
)
Button(onClick = onRetry) {
Text("Повторить")
}
}
}
}
@@ -1,28 +1,299 @@
package agzam.vyatsu.ui.settings
import agzam.vyatsu.BuildConfig
import agzam.vyatsu.R
import agzam.vyatsu.update.UpdateState
import android.widget.HorizontalScrollView
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: UpdateViewModel) {
val autoEnabled by viewModel.autoEnabled.collectAsState()
val updateState by viewModel.updateState.collectAsState()
Scaffold(
modifier = Modifier,
contentWindowInsets = WindowInsets(0.dp),
topBar = {
TopAppBar(
windowInsets = WindowInsets(0.dp),
title = {
Text(
text = "Настройки",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
}
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
SectionTitle("Обновления")
}
item {
SettingsGroup {
SettingsRow(
icon = painterResource(R.drawable.cached_24dp),
title = "Автоматические обновления",
subtitle = "Проверять новые версии при запуске",
trailing = {
Switch(
checked = autoEnabled,
onCheckedChange = viewModel::setAutoEnabled
)
},
onClick = {
viewModel.setAutoEnabled(!autoEnabled)
}
)
HorizontalDivider(
modifier = Modifier.padding(start = 72.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
SettingsRow(
icon = painterResource(R.drawable.download_24dp),
title = "Проверить обновления",
subtitle = updateStatusText(
updateState,
autoEnabled
),
trailing = {
Icon(
painter = painterResource(R.drawable.chevron_right_24dp),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
onClick = viewModel::checkNow
)
}
}
if (
autoEnabled &&
(updateState is UpdateState.Available ||
updateState is UpdateState.ReadyToInstall)
) {
item {
SettingsGroup {
SettingsRow(
icon = painterResource(R.drawable.download_done_24dp),
title = "Установить обновление",
subtitle = installHint(
updateState,
viewModel
),
onClick = {
when (updateState) {
is UpdateState.Available ->
viewModel.downloadAndInstall()
is UpdateState.ReadyToInstall ->
viewModel.installDownloadedApk()
else -> Unit
}
}
)
}
}
}
item {
SectionTitle("О приложении")
}
item {
SettingsGroup {
SettingsRow(
icon = painterResource(R.drawable.info_24dp),
title = "Версия приложения",
subtitle = "Текущая установленная версия",
trailing = {
Text(
text = BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
}
}
}
@Composable
private fun SectionTitle(
title: String
) {
Text(
text = title,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp)
)
}
@Composable
private fun SettingsGroup(
content: @Composable ColumnScope.() -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow
) {
Column(content = content)
}
}
@Composable
private fun SettingsRow(
icon: Painter,
title: String,
subtitle: String? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
} else {
Modifier
}
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
modifier = Modifier.size(44.dp),
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Box(
contentAlignment = Alignment.Center
) {
Icon(
painter = icon,
contentDescription = null
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}
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
fun SettingsScreen(viewModel: UpdateViewModel) {
val autoEnabled by viewModel.autoEnabled.collectAsState()
@@ -87,36 +358,10 @@ fun SettingsScreen(viewModel: UpdateViewModel) {
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,
@@ -150,3 +395,5 @@ private fun SettingsItem(
private fun SettingsDivider() {
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
}
*/
@@ -1,8 +1,11 @@
package agzam.vyatsu.ui.teacher
import agzam.vyatsu.R
import agzam.vyatsu.ui.schedule.components.DayChips
import agzam.vyatsu.ui.schedule.components.FULL_DATE
import agzam.vyatsu.ui.schedule.components.LessonSlot
import agzam.vyatsu.ui.schedule.components.ScheduleDateHeader
import agzam.vyatsu.ui.schedule.components.ScheduleError
import agzam.vyatsu.ui.schedule.components.WeekSelector
import agzam.vyatsu.ui.schedule.components.dayName
import agzam.vyatsu.ui.schedule.components.hPadding
@@ -17,7 +20,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
@@ -28,6 +33,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -38,8 +44,231 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TeacherScheduleScreen(
viewModel: TeacherScheduleViewModel,
onSwitchTeacher: () -> Unit
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
contentWindowInsets = WindowInsets(0.dp),
topBar = {
TopAppBar(
windowInsets = WindowInsets(0.dp),
title = {
Column {
Text(
text = state.teacherName ?: "Расписание",
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Расписание преподавателя",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
actions = {
if (state.isLoading && state.schedule != null) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
}
IconButton(
onClick = { viewModel.refresh() }
) {
Icon(
painter = painterResource(R.drawable.refresh_24dp),
contentDescription = "Обновить"
)
}
IconButton(
onClick = onSwitchTeacher
) {
Icon(
painter = painterResource(R.drawable.sync_alt_24dp),
contentDescription = "Сменить преподавателя"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when {
state.schedule == null && state.loadError -> {
ScheduleError(
onRetry = viewModel::refresh
)
}
state.schedule == null -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
else -> {
val schedule = state.schedule!!
val weekDays = schedule.days
val periods = state.weeks.ifEmpty {
listOf(schedule.period)
}
if (weekDays.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Нет расписания на эту неделю")
}
} else {
val selectedDayIndex = weekDays
.indexOfFirst { it.date == state.dayDate }
.coerceAtLeast(0)
val pagerState = rememberPagerState(
initialPage = selectedDayIndex,
pageCount = { weekDays.size }
)
// Синхронизация выбора дня с Pager
LaunchedEffect(
state.dayDate,
weekDays
) {
val targetPage = weekDays.indexOfFirst {
it.date == state.dayDate
}
if (
targetPage >= 0 &&
targetPage != pagerState.currentPage
) {
pagerState.scrollToPage(targetPage)
}
}
// Переключение дня свайпом
LaunchedEffect(pagerState, weekDays) {
snapshotFlow {
pagerState.settledPage
}.collect { page ->
val selectedDate =
weekDays.getOrNull(page)?.date
if (
selectedDate != null &&
selectedDate != state.dayDate
) {
viewModel.selectDay(selectedDate)
}
}
}
ScheduleDateHeader(
date = state.dayDate,
todayDate = state.todayDate,
periods = periods,
selectedWeekStart = state.weekStart,
onSelectWeek = viewModel::selectWeek
)
DayChips(
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
pagerState = pagerState,
onSelect = viewModel::selectDay
)
HorizontalDivider()
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f),
key = { page ->
weekDays[page].date.toEpochDay()
}
) { page ->
val day = weekDays.getOrNull(page)
val visibleSlots = day?.intervals
?.withIndex()
?.filter { (_, slot) ->
slot.lessons.any { lesson ->
!lesson.subject.isNullOrBlank() ||
lesson.raw.isNotBlank()
}
}
.orEmpty()
if (visibleSlots.isEmpty()) {
ScheduleEmptyDay()
} else {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement =
Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
top = 16.dp,
bottom = 24.dp
)
) {
items(
items = visibleSlots,
key = { it.index }
) { (slotIndex, slot) ->
LessonSlot(
slotIndex = slotIndex,
slot = slot,
showTeacher = false
)
}
}
}
}
}
}
}
}
}
}
@Composable
fun ScheduleEmptyDay() {
TODO("Not yet implemented")
}
/*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TeacherScheduleScreen(viewModel: TeacherScheduleViewModel, onSwitchTeacher: () -> Unit) {
@@ -185,4 +414,6 @@ fun TeacherScheduleScreen(viewModel: TeacherScheduleViewModel, onSwitchTeacher:
}
}
}
}
}
*/