feat(ui): add combined group/teacher picker with segments
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
package agzam.vyatsu
|
package agzam.vyatsu
|
||||||
|
|
||||||
import agzam.vyatsu.di.AppContainer
|
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.home.HomeScreen
|
||||||
|
import agzam.vyatsu.ui.picker.PickerScreen
|
||||||
|
import agzam.vyatsu.ui.picker.PickerViewModel
|
||||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||||
import agzam.vyatsu.ui.settings.UpdateViewModel
|
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -52,10 +52,11 @@ fun AppRoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.noGroup) {
|
if (state.noGroup) {
|
||||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
val pickerViewModel: PickerViewModel = viewModel(factory = pickerFactory(container))
|
||||||
GroupPickerScreen(
|
PickerScreen(
|
||||||
viewModel = groupPickerViewModel,
|
viewModel = pickerViewModel,
|
||||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) },
|
||||||
|
onTeacherPicked = { }
|
||||||
)
|
)
|
||||||
return
|
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 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) }
|
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