feat(ui): add combined group/teacher picker with segments
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import agzam.vyatsu.di.AppContainer
|
||||
import agzam.vyatsu.ui.groups.GroupPickerScreen
|
||||
import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
||||
import agzam.vyatsu.ui.home.HomeScreen
|
||||
import agzam.vyatsu.ui.picker.PickerScreen
|
||||
import agzam.vyatsu.ui.picker.PickerViewModel
|
||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -52,10 +52,11 @@ fun AppRoot() {
|
||||
}
|
||||
|
||||
if (state.noGroup) {
|
||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||
GroupPickerScreen(
|
||||
viewModel = groupPickerViewModel,
|
||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
||||
val pickerViewModel: PickerViewModel = viewModel(factory = pickerFactory(container))
|
||||
PickerScreen(
|
||||
viewModel = pickerViewModel,
|
||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) },
|
||||
onTeacherPicked = { }
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -72,5 +73,12 @@ 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 groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
||||
private fun pickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory {
|
||||
PickerViewModel(
|
||||
c.groupRepository,
|
||||
c.teacherRepository,
|
||||
c.teacherScheduleRepository,
|
||||
c.preferencesRepository
|
||||
)
|
||||
}
|
||||
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package agzam.vyatsu.ui.groups
|
||||
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
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.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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.ExperimentalMaterial3Api
|
||||
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.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
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
|
||||
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.unit.dp
|
||||
|
||||
/**
|
||||
* Страница выбора группы
|
||||
* @author Agzam
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupPickerScreen(viewModel: GroupPickerViewModel, onGroupPicked: (Group) -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
if (!isSearchActive) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Выберите группу",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading && state.groups.isEmpty()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
if (state.error != null && state.groups.isEmpty()) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(state.error.orEmpty(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = { viewModel.retry() }) { Text("Повторить") }
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = state.searchQuery,
|
||||
onQueryChange = { viewModel.search(it) },
|
||||
onSearch = { isSearchActive = false },
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
placeholder = { Text("Поиск группы") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (isSearchActive && state.searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.search("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Очистить")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
GroupList(
|
||||
groups = state.filteredGroups,
|
||||
onPick = { group ->
|
||||
isSearchActive = false
|
||||
onGroupPicked(group)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSearchActive) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
GroupList(state.filteredGroups, onGroupPicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(groups) { group ->
|
||||
Column(modifier = Modifier.fillMaxWidth() .clickable { onPick(group) }) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
||||
// XXX: стоит ли добавлять факультет или это засорит интерфейс
|
||||
// Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
|
||||
// if (group.faculty.isNotEmpty()) Text(text = group.faculty + " · ", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
// }
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package agzam.vyatsu.ui.groups
|
||||
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* @author Agzam
|
||||
*/
|
||||
class GroupPickerViewModel(private val repository: GroupRepository) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(GroupPickerUiState())
|
||||
val uiState: StateFlow<GroupPickerUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val ok = try {
|
||||
repository.refreshIfStale()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось загрузить группы"
|
||||
)
|
||||
false
|
||||
}
|
||||
if (!ok && _uiState.value.groups.isEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = _uiState.value.error ?: "Не удалось обновить список групп"
|
||||
)
|
||||
}
|
||||
}
|
||||
repository.observeGroups().collect { groups ->
|
||||
val query = _uiState.value.searchQuery
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
groups = groups,
|
||||
filteredGroups = filterGroups(groups, query),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
val groups = _uiState.value.groups
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchQuery = query,
|
||||
filteredGroups = filterGroups(groups, query)
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
|
||||
if (query.isBlank()) return groups
|
||||
val lowerQuery = query.lowercase()
|
||||
return groups.filter { group ->
|
||||
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
load()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
repository.forceRefresh()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Не удалось обновить список групп"
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class GroupPickerUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val groups: List<Group> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val filteredGroups: List<Group> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,336 @@
|
||||
package agzam.vyatsu.ui.picker
|
||||
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.Teacher
|
||||
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.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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PickerScreen(
|
||||
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 ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||
SegmentedButton(
|
||||
selected = segment == PickerSegment.GROUPS,
|
||||
onClick = { segment = PickerSegment.GROUPS },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
|
||||
) {
|
||||
Text("Группы")
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = segment == PickerSegment.TEACHERS,
|
||||
onClick = { segment = PickerSegment.TEACHERS },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
|
||||
) {
|
||||
Text("Преподаватели")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
when (segment) {
|
||||
PickerSegment.GROUPS -> GroupSection(
|
||||
state = state,
|
||||
onSearch = viewModel::searchGroup,
|
||||
onRetry = viewModel::retryGroups,
|
||||
onRefresh = viewModel::refreshGroups,
|
||||
onGroupPicked = onGroupPicked
|
||||
)
|
||||
PickerSegment.TEACHERS -> TeacherSection(
|
||||
state = state,
|
||||
onSearch = viewModel::searchTeacher,
|
||||
onRetry = viewModel::retryTeachers,
|
||||
onTeacherPicked = onTeacherPicked
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun GroupSection(
|
||||
state: PickerUiState,
|
||||
onSearch: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onGroupPicked: (Group) -> Unit
|
||||
) {
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
|
||||
if (!isSearchActive) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Выберите группу",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.groupsLoading && state.groups.isEmpty()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.error != null && state.groups.isEmpty()) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
state.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text("Повторить") }
|
||||
}
|
||||
return
|
||||
}
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = state.groupQuery,
|
||||
onQueryChange = onSearch,
|
||||
onSearch = { isSearchActive = false },
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
placeholder = { Text("Поиск группы") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (isSearchActive && state.groupQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { onSearch("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Очистить")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
GroupList(
|
||||
groups = state.filteredGroups,
|
||||
onPick = { group ->
|
||||
isSearchActive = false
|
||||
onGroupPicked(group)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSearchActive) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
GroupList(state.filteredGroups, onGroupPicked)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(groups) { group ->
|
||||
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(group) }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TeacherSection(
|
||||
state: PickerUiState,
|
||||
onSearch: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onTeacherPicked: (Teacher) -> Unit
|
||||
) {
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
val progress = state.syncProgress
|
||||
val syncing = progress != null && progress.done < progress.total
|
||||
|
||||
if (!isSearchActive) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Выберите преподавателя",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onRetry) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (syncing) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.done / progress.total.toFloat() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Кафедры: ${progress.done}/${progress.total}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.teachersLoading && state.teachers.isEmpty() && !syncing) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.error != null && state.teachers.isEmpty() && !syncing) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
state.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text("Повторить") }
|
||||
}
|
||||
return
|
||||
}
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = state.teacherQuery,
|
||||
onQueryChange = onSearch,
|
||||
onSearch = { isSearchActive = false },
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
placeholder = { Text("Поиск преподавателя") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (isSearchActive && state.teacherQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { onSearch("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Очистить")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
TeacherList(
|
||||
teachers = state.filteredTeachers,
|
||||
onPick = { teacher ->
|
||||
isSearchActive = false
|
||||
onTeacherPicked(teacher)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSearchActive) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
when {
|
||||
state.teachers.isEmpty() -> Text(
|
||||
text = "Кафедры без расписания на эту неделю",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
else -> TeacherList(state.filteredTeachers, onTeacherPicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeacherList(teachers: List<Teacher>, onPick: (Teacher) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(teachers) { teacher ->
|
||||
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(teacher) }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(teacher.name, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package agzam.vyatsu.ui.picker
|
||||
|
||||
enum class PickerSegment { GROUPS, TEACHERS }
|
||||
@@ -0,0 +1,188 @@
|
||||
package agzam.vyatsu.ui.picker
|
||||
|
||||
import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.BulkSyncProgress
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import agzam.vyatsu.domain.model.Teacher
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.TeacherRepository
|
||||
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.time.DayOfWeek
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class PickerUiState(
|
||||
val segment: PickerSegment = PickerSegment.GROUPS,
|
||||
val groups: List<Group> = emptyList(),
|
||||
val groupQuery: String = "",
|
||||
val filteredGroups: List<Group> = emptyList(),
|
||||
val groupsLoading: Boolean = true,
|
||||
val teachers: List<Teacher> = emptyList(),
|
||||
val teacherQuery: String = "",
|
||||
val filteredTeachers: List<Teacher> = emptyList(),
|
||||
val teachersLoading: Boolean = true,
|
||||
val syncProgress: BulkSyncProgress? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class PickerViewModel(
|
||||
private val groupRepository: GroupRepository,
|
||||
private val teacherRepository: TeacherRepository,
|
||||
private val teacherScheduleRepository: TeacherScheduleRepository,
|
||||
private val preferencesRepository: PreferencesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PickerUiState())
|
||||
val uiState: StateFlow<PickerUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var teachersJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
groupsLoad()
|
||||
teachersLoad()
|
||||
}
|
||||
|
||||
fun selectSegment(segment: PickerSegment) {
|
||||
_uiState.value = _uiState.value.copy(segment = segment)
|
||||
}
|
||||
|
||||
private fun groupsLoad() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
groupRepository.refreshIfStale()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось загрузить группы"
|
||||
)
|
||||
}
|
||||
}
|
||||
groupRepository.observeGroups().collect { groups ->
|
||||
val query = _uiState.value.groupQuery
|
||||
_uiState.value = _uiState.value.copy(
|
||||
groupsLoading = false,
|
||||
groups = groups,
|
||||
filteredGroups = filterGroups(groups, query),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchGroup(query: String) {
|
||||
val groups = _uiState.value.groups
|
||||
_uiState.value = _uiState.value.copy(
|
||||
groupQuery = query,
|
||||
filteredGroups = filterGroups(groups, query)
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
|
||||
if (query.isBlank()) return groups
|
||||
val lowerQuery = query.lowercase()
|
||||
return groups.filter { group ->
|
||||
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun retryGroups() {
|
||||
_uiState.value = _uiState.value.copy(groupsLoading = true, error = null)
|
||||
groupsLoad()
|
||||
}
|
||||
|
||||
fun refreshGroups() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
groupRepository.forceRefresh()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось обновить список групп"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun teachersLoad() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
teacherRepository.refreshIfStale()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось загрузить кафедры"
|
||||
)
|
||||
}
|
||||
}
|
||||
syncAndObserveTeachers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncAndObserveTeachers() {
|
||||
val today = ScheduleDates.moscowToday()
|
||||
val weekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||
val period = SchedulePeriod(
|
||||
weekStart = weekStart,
|
||||
weekEnd = weekStart.plusDays(6),
|
||||
season = ScheduleDates.currentSeason(today)
|
||||
)
|
||||
|
||||
_uiState.value = _uiState.value.copy(syncProgress = null, teachersLoading = true)
|
||||
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
teacherScheduleRepository.syncPeriod(today).collect { progress ->
|
||||
_uiState.value = _uiState.value.copy(syncProgress = progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teachersJob?.cancel()
|
||||
teachersJob = viewModelScope.launch {
|
||||
teacherScheduleRepository.observeTeachers(period).collect { teachers ->
|
||||
val query = _uiState.value.teacherQuery
|
||||
_uiState.value = _uiState.value.copy(
|
||||
teachersLoading = false,
|
||||
teachers = teachers,
|
||||
filteredTeachers = filterTeachers(teachers, query),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchTeacher(query: String) {
|
||||
val teachers = _uiState.value.teachers
|
||||
_uiState.value = _uiState.value.copy(
|
||||
teacherQuery = query,
|
||||
filteredTeachers = filterTeachers(teachers, query)
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterTeachers(teachers: List<Teacher>, query: String): List<Teacher> {
|
||||
if (query.isBlank()) return teachers
|
||||
val lowerQuery = query.lowercase()
|
||||
return teachers.filter { teacher -> teacher.name.lowercase().contains(lowerQuery) }
|
||||
}
|
||||
|
||||
fun retryTeachers() {
|
||||
syncAndObserveTeachers()
|
||||
}
|
||||
|
||||
fun selectTeacher(teacher: Teacher) {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.clearSelectedGroup()
|
||||
preferencesRepository.setSelectedTeacherId(teacher.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user