Перейти к основному содержанию
`Ctrl`+`K`

# Контроллер представления

VCI (View-Controller Interface) располагается между серверной логикой `State` и Jetpack Compose-представлением.
Ключевые функции контроллера:

- обновление экрана

- управление доступными действиями пользователя

- управление визуальными компонентами

- хранение данных главного потока
  Контроллер переживает пересоздание представления, например при повороте экрана.

- предоставление навигационных функций

Внимание

Весь код, связанный с UI-слоем, должен находиться именно в представлении,
а бизнес-данные остаются внутри соответствующего State.

## Создание контроллера

Контроллер представления должен наследовать базовый класс `SmStateVciAbst<S : SmState>`.

### Шаблон

    class UsersVci : SmStateVciAbst<UsersState>() {

        /* nullable RecordList, чтобы не использовать lateinit */
        private var usersORL = nullObservableRecordList

        override fun onInit(state: UsersState) {
            usersORL = state.usersRS.observableRecordList
        }

        override fun afterEnter(state: UsersState) {
            usersORL = state.usersRS.observableRecordList      // подписываемся на изменения
        }

        override fun newScreen() = provideUsersView(this)

        override fun getTitle() = "Пользователи"
    }

## Жизненный цикл

Фаза | Описание
--- | ---
`onInit(state)` | Вызывается один раз, когда VCI создан. Подписаться на `RecordSet`, настроить меню.
`afterEnter(state)` | Каждый раз, когда `State` становится активным (после `SmTrans` или `postSharedTask`). Обновить UI-данные, получить результат вычислений в стейте.
`beforeExit(state)` | Перед уходом со страницы. Сохранить позицию списков, закрыть диалоги, отправить накопленные изменения в стейт.

## Общие свойства и методы

- `navigator` — Навигатор

- `lazyListState` — используется для хранения позиции основного списка представления

## Работа с данными

### Инициализация данных

Kotlin требует, чтобы все значения по возможности были инициализированы. Для удобства в контроллере объявлены начальные значения:

- `nullObservableRecordList`

- `nullObservableRecord`

### Подписка на набор строк

Compose-экраны не могут обращаться к БД и ресурсам серверного потока напрямую. Поэтому данные обычно собираются в серверном потоке и сохраняются в состоянии. Для работы с данными, сохранёнными в состоянии, используется механизм привязки. Это позволяет безопасно использовать данные в главном потоке, полученные из серверного потока.

Классы, публикующие данные для состояния:

- `RecordSet`

- `SingleRecord`

- `RecordValue`

Внутри себя они содержат:

- `observableRecordList`

- `observableRecordHolder`

Это позволяет использовать стандартные механизмы подписки Compose.

Привязка происходит в момент инициализации:

    override fun onInit(state: UsersState) {
        usersORL = state.usersRS.observableRecordList      // подписались один раз
    }

Пример использования привязанных данных:

    @Composable
    fun UsersView(vci: UsersVci, pv: PaddingValues) {
        val users by vci.usersORL.observeAsState()          // ← живой список

        LazyColumn(Modifier.padding(pv)) {
            items(users) { or ->
                Text(or.getValueAsString("name"))
            }
        }
    }

Подписка происходит в главном потоке. Все изменения данных, пришедшие из серверного потока, автоматически попадают в Compose.

### Чтение и модификация значений

Каждый элемент списка — это `RecordData`.
Доступ к полям идёт через геттеры/сеттеры.

Пример чтения:

    val email = or.getValueAsString("email")
    val age   = or.getValueAsInt   ("age")

Пример изменения:

    IconButton(onClick = { or.setValue("isSelected", 1) }) { /* ... */ }

Примечание

Синхронизация изменений между главным потоком и серверным потоком происходит в `beforeExit()` VCI.
Когда пользователь завершит работу с экраном, мы можем сбросить изменения в БД одной пачкой.

## Планирование серверных команд

Основные понятия:

- задачи — выполняются в контексте текущего `State`, не меняют стек состояний. Одновременно может
  быть запланировано несколько задач.

- переходы — добавляют или удаляют состояния из стека состояний.
  Одновременно может быть запланирован только один переход.
  Переход происходит транзакционно: SQLite-транзакция, `FileManager` и пользовательский экран
  согласованно перейдут в новое состояние или, в случае ошибки, произойдёт откат до начала выполнения перехода.

См. также

- `StateEventProcessor`

Для отправки действий в серверный поток используется планировщик `getSts()`.

Методы `getSts()`:

- `postSharedTask`:

      withLock("Загрузка…") {
          getSts<UsersState>().postSharedTask("refresh") { st ->
              st.dbs.getApi(UserApi::class).updateFromServer()
          }
          .onSuccess {
              // успешное завершение
              // можем запланировать еще работу, переходы и т.д.
              // например, getSts().postBack{}
          }
          .onFailure {
              showBaseDialog("Ошибка", it.msg)
          }
      }

  Совет

  Используйте `withLock`, чтобы надёжно показать индикатор и гарантированно его убирать.

- `postSharedTask()`
  Перед/после выполнения задачи происходит синхронизация данных между контроллерами.

- `postAsyncTask()`
  Выполняет задачу без событий синхронизации.

- `postCall()`
  Планирование перехода, при этом состояние добавляется в стек состояний; основной метод перехода вперёд.

- `postBack()`
  Планирование перехода назад (снятие состояния со стека); основной метод перехода назад.

- `postCallWithResult()`
  Планирование перехода вперёд с ожиданием результата.

- `postBackWithResult()`
  Планирование перехода назад (снятие состояния со стека) с результатом для ожидающего состояния.

Внимание

Коллбеки `onSuccess / onFailure` приходят в главном потоке, блокировка UI не требуется.

Чтобы получить результат `postCallWithResult`, нужно вызвать зеркальный метод `postBackWithResult`.
Если результат не будет передан при возвращении, произойдёт ошибка; сам результат обрабатывается в параметре `onResult`.

При попытке вызвать `postBackWithResult` без ожидающего состояния также будет выброшена ошибка.
Таким образом, разработчик всегда знает, когда что-то не пришло или было отправлено по ошибке.

### Примеры для каждого варианта:

#### `postSharedTask` — задача с синхронизацией и обработкой результата

    fun refreshUsers() {
        showLoadingView()
        getSts().postSharedTask("refreshUsers") { st ->
            st.dbs.getApi(UserApi::class).updateFromServer()
        }
        .onSuccess {

        }
        .onFailure {
            stopLoadingView()
            showBaseDialog(
                title = "Ошибка",
                msg = it.message ?: "Не удалось обновить данные",
                isError = true
            )
        }
    }

#### `postAsyncTask` — без синхронизации

    fun sendAnalyticsEvent(event: String) {
        getSts().postAsyncTask("analyticsEvent") { st ->
            st.dbs.execSql(
                "INSERT INTO AnalyticsLog(event) VALUES(?)",
                event
            )
        }
        // без `afterEnter`
    }

#### `postCall` / `postCallState` — переход на новый экран

    // Переход на экран камеры без результата
    fun toCamera() {
        showLoadingView()
        getSts().postCallState<GsCameraState> { tb ->
            tb.onBefore { bb ->
                bb.afterSubscribe { stTo ->
                    stTo.cameraUseCaseState = GsCameraUseCaseState.IMAGE_CAPTURE
                }
            }
        }
    }

#### `postBack` — возврат назад

    override fun smPostBack() {
        if (mediaPreviewState.value == MediaPreviewState.HIDDEN) {
            getSts().postBack { /* можно настроить onBefore/onAfter, если нужно */ }
        } else {
            mediaPreviewState.value = MediaPreviewState.HIDDEN
            showBottomBar()
        }
    }

#### `postCallStateWithResult` — переход с ожиданием результата

Пример: открываем камеру, ждём результат `CameraResult`, обрабатываем его в текущем VCI.

    fun toQrScanner() {
        showLoadingView()

        getSts().postCallStateWithResult<GsCameraState, CameraResult>(
            body = { tb ->
                tb.onBefore { bb ->
                    bb.afterSubscribe { stTo ->
                        stTo.cameraUseCaseState = GsCameraUseCaseState.QRCODE_SCANNER
                        stTo.qrDelegate = this@CreateDemandVci
                    }
                }
            },
            onResult = { result ->
                stopLoadingView()
                if (result is CameraResult.Qr) {
                    showBaseDialog(
                        title = "Информация",
                        msg = result.text
                    )
                }
            }
        )
    }

#### `postBackWithResult` — возврат назад с результатом

Экран-дочерний возвращает результат родителю:

    fun backWithQr(qrCode: String) {
        showLoadingView()
        getSts().postBackWithResult(CameraResult.Qr(qrCode))
    }

    fun backErrorWithQr(e: Throwable) {
        showLoadingView()
        getSts().postBackWithResult(CameraResult.Error(e))
    }

### Композиция бизнес-логики

- Используйте `getSts` только из VCI.
  Это позволит:

  - избежать ошибок доступа к данным из разных потоков;

  - повысить читаемость кода.

    @Composable
    fun Toolbar(vci: UsersVci) {
        IconButton(onClick = { vci.refreshUsers() }) { /* ... */ }
    }

    // в VCI
    fun refreshUsers() {
        showLoadingView()
        getSts().postSharedTask("refresh") { st ->
            st.dbs.getApi(UserApi::class).syncFromServer()
        }.onSuccess {
            usersORL.refresh()         // обновляем список
            stopLoadingView()
        }.onFailure {
            showBaseDialog(
                "Ошибка",
                it.message ?: "Не удалось обновить данные",
                isError = true
            )
        }
    }

## Меню

Список пунктов меню задаётся в событии `afterEnter`:

    override fun afterEnter(state: UsersState) {
        topGsMenuItems.setOf(
            GsMenuItem("Обновить") { refresh() },
            GsMenuItem("Выход")   { navigator.doLogout() }
        )

        bottomGsMenuItems.single("Добавить") { addUser() }
    }

## События навигации

- `onBack` — обработчик кнопки «Назад».

## Расширение навигатора

Методы расширения навигатора объявлены в интерфейсе `NavigableVcp` и могут быть реализованы в
контроллере представления.

Можно переопределить:

- `newScreen` — представление типа VcpScreen

- `getTitle` — заголовок

- `topGsMenuItems` — элементы `TopBar`

- `bottomGsMenuItems` — элементы `BottomBar`

- `showFAB` — видимость FAB

- `Fab` — собственная реализация `@Composable Fab()`

- `fabClick` — действия на FAB

### Пример FAB

    override fun showFAB() = usersORL.size > 0

    override fun Fab() =
        SmallFloatingActionButton(onClick = ::addUser) {
            Icon(Icons.Default.PersonAdd, contentDescription = null)
        }

## Стандартные диалоги

    showBaseDialog(
        title = "Удалить пользователя?",
        okText = "Да",
        dismissText = "Нет",
        onOk = ::confirmDelete
    )

## Event Bus

`Flow`-шина событий работает на всё приложение:

    subscribeEventBus<SyncDone>("SyncDone") {
        navigator.setNeedSync(false)
    }

## VcpScreen

Класс-адаптер, который отдаёт Composable-дерево как функцию `Content(pv: PaddingValues)`.
Именно `screen` попадает в `Navigator` для реального рендеринга во вкладку стека.
`PaddingValues` определяет размеры отступов для меню.

## Стандартные делегаты

Делегаты типизируют стандартные события для обработки сообщений.

Стандартные делегаты:

- `QrCodeScannerDelegate`

Внимание

Экспериментальный функционал, находится в разработке.

## Полный пример

    class DemandListVci : SmStateVciAbst<DemandListState>() {

        private var demandORL = nullObservableRecordList
        val isRefreshing = mutableStateOf(false)

        /* 1. Init one time */
        override fun onInit(state: DemandListState) {
            demandORL = state.demandRS.observableRecordList
            onBack { smVci -> smVci.smPostBack() }
        }

        /* 2. Every enter */
        override fun afterEnter(state: DemandListState) {
            demandORL = state.demandRS.observableRecordList
        }

        /* 3. Exit */
        override fun beforeExit(state: DemandListState) = Unit

        /* 4. UI */
        override fun newScreen() = provideDemandListView(this)
        override fun getTitle() = "Заявки"

        /* 5. Actions */
        fun refresh() {
            showLoadingView()
            getSts<DemandListState>().postSharedTask("refresh") { st ->
                st.dbs.getApi(EamDemandApi::class).syncFromServer()
            }.onSuccess {
                stopLoadingView()
            }.onFailure {
                stopLoadingView()
                showBaseDialog("Ошибка", it.message ?: "")
            }
        }
    }

## Хорошие практики

- Не держите ссылок на `RecordData`, всегда получайте новую через `observeAsState()`.
  Каждый раз там будет актуальная строка.

- Вызывайте только публичные методы VCI.
  Не трогайте `dbs` из Composable.

- Из VCI в `State` переходите через `postSharedTask()/postAsyncTask()`.
  Никогда не ходите в `State` напрямую.

- Держите бизнес-логику в `State`.

- Держите сетевые/SQL-операции — в `Api`/`Pkg`.

- Помните: VCI — это тонкий контроллер UI.
  Он должен оставаться ответственным за подписки, показ, навигацию и т.д.
  Изоляция VCI позволяет делать код простым, тестируемым и устойчивым к изменениям.

- Используйте собственные `ExecutorSubscription` для Bluetooth, камеры, видео и т.д. вместо `postAsyncTask`.
  Это снизит нагрузку на серверный поток.

## Плохие практики

- Не злоупотребляйте методом `refreshView()`.
  Этот метод уже вызывается навигатором, ручной вызов нужен только в особых случаях.